From 33588981ae38c6a7d6380024e014dad5cc07abd0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 19:59:40 -0700 Subject: [PATCH 1/5] Bake mixed JSON tables and destructure unions of arrays and tuples JSON documents whose rows mix strings and numbers (`(string | number)[][]`, `(string | number[])[][]`, the character-set and code-point tables of iconv-lite and tr46) now bake: comptime literals take union-typed elements and pick each value's arm from its JSON kind and contents. Array destructuring of a union of array and tuple types (`const [m, a, b] = re.exec(s) || ["", "", ""]`) reads each position from the arm the value holds: a tuple arm answers its field or undefined past its end, an array arm its element or undefined past its end. Unions of tuple types no longer pick the checker's `length` literal as a discriminant: tuple shapes have no such slot. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/array-values.ts | 60 ++++++++++++--- .../src/frontend/lowering/lower-comptime.ts | 73 ++++++++++++++++++- .../src/frontend/lowering/lower-stmts.ts | 12 ++- packages/compiler/src/frontend/type-mapper.ts | 3 + .../main.ts.json | 6 ++ ...11-union-array-tuple-destructuring.ts.json | 6 ++ .../6410-json-mixed-scalar-tables/extra.json | 1 + .../6410-json-mixed-scalar-tables/main.ts | 21 ++++++ .../node_modules/code-table/index.d.ts | 2 + .../node_modules/code-table/index.js | 10 +++ .../node_modules/code-table/package.json | 1 + .../node_modules/code-table/table.json | 1 + .../node_modules/range-map/index.d.ts | 2 + .../node_modules/range-map/index.js | 37 ++++++++++ .../node_modules/range-map/package.json | 1 + .../node_modules/range-map/table.json | 1 + .../6411-union-array-tuple-destructuring.ts | 34 +++++++++ 17 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/extra.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/main.ts create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json create mode 100644 tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json create mode 100644 tests/corpus/6411-union-array-tuple-destructuring.ts diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index 8f2b28a3f4..e9148b244d 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -84,8 +84,11 @@ export function arrayValueRead( }; } -/** Read a stabilized union of native arrays without copying either the - * receiver or its elements. Every arm contributes its missing-value case. */ +/** Read one position of a stabilized union of native arrays and tuples + * (`const [a, b] = re.exec(s) || ["", ""]`) without copying the receiver + * or its elements. Every array arm contributes its missing-value case; a + * tuple arm answers its field, or undefined past its end. Tuple arms need + * a constant position. */ export function unionArrayValueRead( lowerer: Lowerer, value: IrExpr, @@ -96,17 +99,32 @@ export function unionArrayValueRead( const unionId = value.type.unionId; const arms = lowerer.unions.get(unionId)?.arms; if (!arms?.length) return null; + const position = index.kind === "numLit" ? String(index.value) : null; + // A tuple arm's answer at the position: its field, or undefined past + // its end. Null when the arm is neither an array nor a tuple. + const tupleField = (arm: IrType): { name: string; type: IrType } | "past" | null => { + if (arm.kind !== "record" || position === null) return null; + const shape = lowerer.shapes.get(arm.shapeId); + if (!shape?.tuple) return null; + return shape.fields.find((f) => f.name === position) ?? "past"; + }; // Keep the collection's IrType union layout and narrow each element at // its use; an inferred array predicate changes the collection's ABI. - for (const arm of arms) if (arm.kind !== "array") return null; + for (const arm of arms) if (arm.kind !== "array" && tupleField(arm) === null) return null; const answers = new Map(); for (const arm of arms) { - if (arm.kind !== "array") return null; - const answer = arrayValueType(lowerer, arm.elem); + const field = arm.kind === "array" ? null : tupleField(arm); + const answer = + arm.kind === "array" + ? arrayValueType(lowerer, arm.elem) + : field === "past" + ? UNDEFINED_T + : field!.type; const parts = answer.kind === "union" ? lowerer.unions.get(answer.unionId)!.arms : [answer]; for (const part of parts) answers.set(typeKey(part), part); } const joined = [...answers.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + if (joined.length === 1 && joined[0]!.kind === "undefinedT") return null; if ( joined.length > 1 && (!unionContainerArmsOk(joined) || @@ -118,12 +136,34 @@ export function unionArrayValueRead( let result: IrExpr | null = null; for (let tag = arms.length - 1; tag >= 0; tag--) { const arm = arms[tag]!; - if (arm.kind !== "array") return null; const receiver: IrExpr = { kind: "unionNarrow", unionId, tag, value, type: arm, loc }; - const read = lowerer.coerceToExpected( - arrayValueRead(lowerer, receiver, index, arm.elem, loc), - type, - ); + let read: IrExpr; + if (arm.kind === "array") { + read = lowerer.coerceToExpected( + arrayValueRead(lowerer, receiver, index, arm.elem, loc), + type, + ); + } else { + const field = tupleField(arm); + if (field === null) return null; + if (field === "past") { + const missing = lowerer.wrappedUndefined(type, loc); + if (!missing) return null; + read = missing; + } else { + read = lowerer.coerceToExpected( + { + kind: "recordGet", + obj: receiver, + shapeId: (arm as IrType & { kind: "record" }).shapeId, + field: field.name, + type: field.type, + loc, + }, + type, + ); + } + } result = result === null ? read diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index 41c6704c59..31fa5afe02 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -4,7 +4,7 @@ import { InternalCompilerError } from "../../errors.js"; * literals — records/arrays/unions included, subject to comptimeBakeable. */ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; -import { type IrExpr, type IrType } from "../../ir/ir.js"; +import { isUnitType, type IrExpr, type IrType } from "../../ir/ir.js"; import { comptimeFailedDiag } from "../../diagnostics/diagnostic.js"; import { locOf } from "../program.js"; import { PoisonError } from "./lowerer.js"; @@ -110,8 +110,9 @@ function comptimeFailureDetail(error: unknown): string { /** True when a comptime result of this type can be written as a literal * expression: number/string/boolean, arrays and record shapes of those, - * nested. Closures, class instances, unions, promises, dyn, and void - * cannot be baked. */ + * and unions of those with null or undefined (the mixed rows of a JSON + * table: `(string | number)[][]`), nested. Closures, class instances, + * promises, dyn, and void cannot be baked. */ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { switch (t.kind) { case "f64": @@ -124,6 +125,52 @@ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { const shape = lowerer.shapes.get(t.shapeId); return !!shape && shape.fields.every((f) => lowerer.comptimeBakeable(f.type)); } + case "union": { + const arms = lowerer.unions.get(t.unionId)?.arms; + return ( + arms !== undefined && + arms.every( + (arm) => isUnitType(arm) || (arm.kind !== "union" && comptimeBakeable(lowerer, arm)), + ) + ); + } + default: + return false; + } +} + +/** Whether `value` bakes into the bakeable type `t` without a mismatch: + * the arm choice of a union literal. Records are exact, as in + * comptimeValueToIr; an absent field reads as undefined. */ +function comptimeFits(lowerer: Lowerer, value: unknown, t: IrType): boolean { + switch (t.kind) { + case "f64": + return typeof value === "number" && Number.isFinite(value); + case "string": + return typeof value === "string"; + case "bool": + return typeof value === "boolean"; + case "nullT": + return value === null; + case "undefinedT": + return value === undefined; + case "array": + return Array.isArray(value) && value.every((el) => comptimeFits(lowerer, el, t.elem)); + case "record": { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const shape = lowerer.shapes.get(t.shapeId); + if (!shape) return false; + const obj = value as Record; + const declared = new Set(shape.fields.map((f) => f.name)); + return ( + Object.keys(obj).every((key) => declared.has(key)) && + shape.fields.every((f) => comptimeFits(lowerer, obj[f.name], f.type)) + ); + } + case "union": + return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => + comptimeFits(lowerer, value, arm), + ); default: return false; } @@ -265,6 +312,26 @@ export function comptimeValueToIr( })); return { kind: "recordLit", fields, type: expected, loc }; } + case "union": { + // The first arm the value fits, in the union's arm order: a JSON + // value fits exactly one scalar arm, and array or record arms are + // told apart by their contents. + const arms = lowerer.unions.get(expected.unionId)!.arms; // bakeable-checked + const tag = arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); + if (tag < 0) fail(describeComptimeValue(value)); + const arm = arms[tag]!; + const payload: IrExpr = isUnitType(arm) + ? { kind: "unitLit", unit: arm.kind === "nullT" ? "null" : "undefined", type: arm, loc } + : lowerer.comptimeValueToIr(value, arm, path, blame); + return { + kind: "unionWrap", + unionId: expected.unionId, + tag, + value: payload, + type: expected, + loc, + }; + } default: // comptimeBakeable already fenced these off. throw new InternalCompilerError(`lowerer bug: unbakeable comptime target '${expected.kind}'`); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 2c36e4973a..444fb899cc 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2379,20 +2379,26 @@ export function lowerBindingPattern( lowerer.fenceStaticHeadersIteration(pattern); if ( srcType.kind === "union" && - lowerer.unions.get(srcType.unionId)?.arms.every((arm) => arm.kind === "array") + lowerer.unions + .get(srcType.unionId) + ?.arms.every( + (arm) => + arm.kind === "array" || + (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple === true), + ) ) { for (let i = 0; i < pattern.elements.nodes.length; i++) { const el = pattern.elements.nodes[i]!; if (ts.isOmittedExpression(el) || el.name === undefined) continue; if (el.dotDotDotToken) - lowerer.unsupported("SC1031", el, "rest elements over a union of array layouts"); + lowerer.unsupported("SC1031", el, "rest elements over a union of array or tuple layouts"); const loc = locOf(el); let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc); if (!value) lowerer.unsupported( "SC1031", el, - "array union elements without a common native representation", + "array or tuple union elements without a common native representation", ); if (el.initializer) value = applyBindingDefault(lowerer, el, value); lowerer.bindPatternTarget(el.name, value, isLet, out); diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 437aba2b89..eea7dd9763 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -4411,6 +4411,9 @@ function unionDiscriminant( ): IrUnionDiscriminant | undefined { if (arms.filter((arm) => arm.kind === "record").length < 2 || parts.length < 2) return undefined; const { checker } = ctx; + // Tuple shapes keep positions only: their checker `length` literal has + // no slot to compare (discriminantField refuses tuple shapes too). + if (parts.some((part) => ctx.shapes.get(part.mapped.shapeId)?.tuple)) return undefined; for (const candidate of checker.getPropertiesOfType(parts[0]!.source)) { if (candidate.name.startsWith("__@")) continue; const byTag = new Map(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json new file mode 100644 index 0000000000..c7ec1ab567 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6410-json-mixed-scalar-tables/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6410-json-mixed-scalar-tables/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json new file mode 100644 index 0000000000..94d8b78e74 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6411-union-array-tuple-destructuring.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6411-union-array-tuple-destructuring.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6410-json-mixed-scalar-tables/extra.json b/tests/corpus/6410-json-mixed-scalar-tables/extra.json new file mode 100644 index 0000000000..927c5d9680 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/extra.json @@ -0,0 +1 @@ +[["x", 1, "two", 3], [4, "five"], ["six"]] diff --git a/tests/corpus/6410-json-mixed-scalar-tables/main.ts b/tests/corpus/6410-json-mixed-scalar-tables/main.ts new file mode 100644 index 0000000000..54e337b02d --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/main.ts @@ -0,0 +1,21 @@ +// JSON tables whose rows mix strings and numbers, the shape of a +// character-set table (`[["8140", 12288, 65292], ...]`) and of a +// code-point range map (`[[[0, 44], "disallowed"], [[65, 65], "mapped", +// [97]], ...]`). A package requires them at load or inside a function, and +// an ES module imports one; each row keeps its strings and numbers. +import { lookup, rowCount } from "code-table"; +import { mapChars, status } from "range-map"; +import extra from "./extra.json" with { type: "json" }; + +console.log(lookup("8150"), lookup("a1"), lookup("zzzz"), rowCount()); +const mapped = mapChars("AB-c9"); +console.log(mapped.text, mapped.error); +console.log(JSON.stringify(mapChars("a,b")), JSON.stringify(mapChars("{"))); +console.log(status(10), status(46), status(66), status(1000)); + +for (const row of extra) { + const head = row[0]; + const rest = row.slice(1); + console.log(typeof head, head, rest.length, rest.map((v) => (typeof v === "number" ? v + 1 : v.toUpperCase())).join(",")); +} +console.log(JSON.stringify(extra)); diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts new file mode 100644 index 0000000000..ae7db1d983 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.d.ts @@ -0,0 +1,2 @@ +export declare function lookup(code: string): number | string; +export declare function rowCount(): number; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js new file mode 100644 index 0000000000..dc10bb817c --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/index.js @@ -0,0 +1,10 @@ +'use strict'; +const table = require('./table.json'); +exports.lookup = function (code) { + for (const row of table) if (row[0] === code) return row[1]; + return -1; +}; +exports.rowCount = function () { + // A second require of the same document answers the same rows. + return require('./table.json').length; +}; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json new file mode 100644 index 0000000000..e15622d878 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/package.json @@ -0,0 +1 @@ +{ "name": "code-table", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json new file mode 100644 index 0000000000..5abaa6772a --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/code-table/table.json @@ -0,0 +1 @@ +[["8140", 12288, 65292], ["8150", 65507, 65340], ["a1", "。", 62]] diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts new file mode 100644 index 0000000000..322bd20e1c --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.d.ts @@ -0,0 +1,2 @@ +export declare function mapChars(s: string): { text: string; error: boolean }; +export declare function status(cp: number): string; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js new file mode 100644 index 0000000000..481d93ad43 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/index.js @@ -0,0 +1,37 @@ +'use strict'; +var table = require('./table.json'); +function findStatus(val) { + var start = 0; + var end = table.length - 1; + while (start <= end) { + var mid = Math.floor((start + end) / 2); + var target = table[mid]; + if (target[0][0] <= val && target[0][1] >= val) { + return target; + } else if (target[0][0] > val) { + end = mid - 1; + } else { + start = mid + 1; + } + } + return null; +} +exports.status = function (cp) { + var found = findStatus(cp); + return found === null ? 'unknown' : found[1]; +}; +exports.mapChars = function (s) { + var error = false; + var out = ''; + for (var i = 0; i < s.length; ++i) { + var cp = s.codePointAt(i); + var found = findStatus(cp); + if (found === null) { error = true; continue; } + switch (found[1]) { + case 'disallowed': error = true; out += String.fromCodePoint(cp); break; + case 'mapped': out += String.fromCodePoint.apply(String, found[2]); break; + case 'valid': out += String.fromCodePoint(cp); break; + } + } + return { text: out, error: error }; +}; diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json new file mode 100644 index 0000000000..bc877e1e80 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/package.json @@ -0,0 +1 @@ +{ "name": "range-map", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json new file mode 100644 index 0000000000..900f9e3165 --- /dev/null +++ b/tests/corpus/6410-json-mixed-scalar-tables/node_modules/range-map/table.json @@ -0,0 +1 @@ +[[[0,44],"disallowed"],[[45,46],"valid"],[[47,47],"disallowed"],[[48,57],"valid"],[[65,65],"mapped",[97]],[[66,67],"mapped",[98,99]],[[97,122],"valid"]] diff --git a/tests/corpus/6411-union-array-tuple-destructuring.ts b/tests/corpus/6411-union-array-tuple-destructuring.ts new file mode 100644 index 0000000000..1dd88fa921 --- /dev/null +++ b/tests/corpus/6411-union-array-tuple-destructuring.ts @@ -0,0 +1,34 @@ +// Array destructuring of a value whose type is a union of array and tuple +// types: a regex match or a default row (`re.exec(s) || ["", "", ""]`), and +// functions answering either a fixed tuple or a list. Each position reads +// the arm the value holds; positions past a tuple's end and past an array's +// end read undefined, and defaults fire exactly there. +function scan(text: string): string { + const [match, head, word] = /(\w*)-(\w+)/.exec(text) || ["", "", ""]; + return `${match}|${head}|${word.toUpperCase()}`; +} +console.log(scan("ab-cd"), scan("none"), scan("-x")); + +function pick(flag: boolean): [string, number] | string[] { + return flag ? ["a", 1] : ["b", "c"]; +} +const [p, q] = pick(true); +const [r, s = "dflt", t = "z"] = pick(false); +const [u, , w = "w"] = pick(true); +console.log(p, q, r, s, t, u, w); +console.log(typeof q, typeof s); + +function pair(n: number): number[] | [number, number] { + return n > 1 ? [n, n * 2] : [n]; +} +const [x, y] = pair(7); +const [x1, y1 = -1] = pair(1); +console.log(x, y, x1, y1); + +function row(kind: string): [string, boolean] | [string, boolean, number] { + return kind === "long" ? [kind, true, 3] : [kind, false]; +} +for (const k of ["long", "short"]) { + const [name, flag, count = 0] = row(k); + console.log(name, flag, count); +} From 52f9bf284f7174c3e0f0163aa676f57591ec25b4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 20:40:56 -0700 Subject: [PATCH 2/5] Destructure a match-or-empty-string union by code point `const [hit] = re.exec(s) || ""` destructures `RegExpExecArray | string`: the string arm answers its code point at each position, or undefined past its end, as the string iterator does. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/array-values.ts | 25 ++++++++++++++----- .../src/frontend/lowering/lower-stmts.ts | 8 ++++-- .../6411-union-array-tuple-destructuring.ts | 8 ++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index e9148b244d..0ca8503df3 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -8,6 +8,7 @@ import { type IrType, isIntegerBytesElem, type SrcLoc, + STRING, UNDEFINED_T, typeEquals, typeKey, @@ -88,12 +89,15 @@ export function arrayValueRead( * (`const [a, b] = re.exec(s) || ["", ""]`) without copying the receiver * or its elements. Every array arm contributes its missing-value case; a * tuple arm answers its field, or undefined past its end. Tuple arms need - * a constant position. */ + * a constant position. With `stringChars` (the string iterator's code + * points as a fresh string[]), a string arm (`re.exec(s) || ""`) answers + * its code point at the position, or undefined past its end. */ export function unionArrayValueRead( lowerer: Lowerer, value: IrExpr, index: IrExpr, loc: SrcLoc, + stringChars?: (text: IrExpr) => IrExpr, ): IrExpr | null { if (value.type.kind !== "union") return null; const unionId = value.type.unionId; @@ -110,16 +114,20 @@ export function unionArrayValueRead( }; // Keep the collection's IrType union layout and narrow each element at // its use; an inferred array predicate changes the collection's ABI. - for (const arm of arms) if (arm.kind !== "array" && tupleField(arm) === null) return null; + const stringArm = (arm: IrType): boolean => arm.kind === "string" && stringChars !== undefined; + for (const arm of arms) + if (arm.kind !== "array" && !stringArm(arm) && tupleField(arm) === null) return null; const answers = new Map(); for (const arm of arms) { - const field = arm.kind === "array" ? null : tupleField(arm); + const field = arm.kind === "array" || stringArm(arm) ? null : tupleField(arm); const answer = arm.kind === "array" ? arrayValueType(lowerer, arm.elem) - : field === "past" - ? UNDEFINED_T - : field!.type; + : stringArm(arm) + ? arrayValueType(lowerer, STRING) + : field === "past" + ? UNDEFINED_T + : field!.type; const parts = answer.kind === "union" ? lowerer.unions.get(answer.unionId)!.arms : [answer]; for (const part of parts) answers.set(typeKey(part), part); } @@ -143,6 +151,11 @@ export function unionArrayValueRead( arrayValueRead(lowerer, receiver, index, arm.elem, loc), type, ); + } else if (stringArm(arm)) { + read = lowerer.coerceToExpected( + arrayValueRead(lowerer, stringChars!(receiver), index, STRING, loc), + type, + ); } else { const field = tupleField(arm); if (field === null) return null; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 444fb899cc..737c2e9ae1 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2384,8 +2384,10 @@ export function lowerBindingPattern( ?.arms.every( (arm) => arm.kind === "array" || + arm.kind === "string" || (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple === true), - ) + ) && + lowerer.unions.get(srcType.unionId)!.arms.some((arm) => arm.kind !== "string") ) { for (let i = 0; i < pattern.elements.nodes.length; i++) { const el = pattern.elements.nodes[i]!; @@ -2393,7 +2395,9 @@ export function lowerBindingPattern( if (el.dotDotDotToken) lowerer.unsupported("SC1031", el, "rest elements over a union of array or tuple layouts"); const loc = locOf(el); - let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc); + let value = unionArrayValueRead(lowerer, srcRef(), numLit(i, loc), loc, (text) => + strCharsCall(lowerer, text, loc), + ); if (!value) lowerer.unsupported( "SC1031", diff --git a/tests/corpus/6411-union-array-tuple-destructuring.ts b/tests/corpus/6411-union-array-tuple-destructuring.ts index 1dd88fa921..f62eba996c 100644 --- a/tests/corpus/6411-union-array-tuple-destructuring.ts +++ b/tests/corpus/6411-union-array-tuple-destructuring.ts @@ -32,3 +32,11 @@ for (const k of ["long", "short"]) { const [name, flag, count = 0] = row(k); console.log(name, flag, count); } + +// A match or an empty string (`re.exec(s) || ""`): a string destructures +// by code point, so the empty string binds undefined. +function firstHit(text: string): string { + const [hit] = /\d+/.exec(text) || ""; + return hit ? `hit ${hit}` : "none"; +} +console.log(firstHit("ab12"), firstHit("ab")); From fe322ae901cbc4914f527c756d8f71235f2454f4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 19 Oct 2026 21:16:25 -0700 Subject: [PATCH 3/5] Keep the JSON-shape diagnostics fixture refused now that null fields bake A null-valued JSON field bakes as null, so the fixture's document now compiles. It keeps its purpose with a value no literal spells (1e400 parses to Infinity), and the mixed-table corpus program covers null elements. Co-Authored-By: Claude Opus 5.5 --- tests/corpus/6410-json-mixed-scalar-tables/extra.json | 2 +- tests/corpus/6410-json-mixed-scalar-tables/main.ts | 7 ++++++- tests/diagnostics/json-shape/main.ts | 8 ++++---- tests/diagnostics/json-shape/nulled.json | 2 +- tests/harness/__snapshots__/json-shape/main.ts.txt | 4 ++-- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/corpus/6410-json-mixed-scalar-tables/extra.json b/tests/corpus/6410-json-mixed-scalar-tables/extra.json index 927c5d9680..7af99c22f2 100644 --- a/tests/corpus/6410-json-mixed-scalar-tables/extra.json +++ b/tests/corpus/6410-json-mixed-scalar-tables/extra.json @@ -1 +1 @@ -[["x", 1, "two", 3], [4, "five"], ["six"]] +[["x", 1, "two", 3], [4, "five", null], ["six"], [null]] diff --git a/tests/corpus/6410-json-mixed-scalar-tables/main.ts b/tests/corpus/6410-json-mixed-scalar-tables/main.ts index 54e337b02d..c60f962dcf 100644 --- a/tests/corpus/6410-json-mixed-scalar-tables/main.ts +++ b/tests/corpus/6410-json-mixed-scalar-tables/main.ts @@ -16,6 +16,11 @@ console.log(status(10), status(46), status(66), status(1000)); for (const row of extra) { const head = row[0]; const rest = row.slice(1); - console.log(typeof head, head, rest.length, rest.map((v) => (typeof v === "number" ? v + 1 : v.toUpperCase())).join(",")); + console.log( + typeof head, + head, + rest.length, + rest.map((v) => (v === null ? "null" : typeof v === "number" ? v + 1 : v.toUpperCase())).join(","), + ); } console.log(JSON.stringify(extra)); diff --git a/tests/diagnostics/json-shape/main.ts b/tests/diagnostics/json-shape/main.ts index 1d95525068..a46338d528 100644 --- a/tests/diagnostics/json-shape/main.ts +++ b/tests/diagnostics/json-shape/main.ts @@ -1,7 +1,7 @@ -/* A JSON document whose shape doesn't bake: a null-valued field has no - * standalone STATIC type, so the import reports the dynamic-family type - * diagnostic (SC2011 at the import site — the shape maps in the embedded - * engine) and uses of the binding poison. */ +/* A JSON document whose value doesn't bake: `1e400` parses to Infinity, + * which no number literal spells, so the import reports the bake failure + * at the import site and uses of the binding poison. (Its null-valued + * field bakes as null.) */ import bad from "./nulled.json" with { type: "json" }; console.log(bad.name); diff --git a/tests/diagnostics/json-shape/nulled.json b/tests/diagnostics/json-shape/nulled.json index fa47384497..5e506a6aff 100644 --- a/tests/diagnostics/json-shape/nulled.json +++ b/tests/diagnostics/json-shape/nulled.json @@ -1 +1 @@ -{ "name": "x", "deprecated": null } +{ "name": "x", "deprecated": null, "limit": 1e400 } diff --git a/tests/harness/__snapshots__/json-shape/main.ts.txt b/tests/harness/__snapshots__/json-shape/main.ts.txt index b03f098a60..85f5d2917f 100644 --- a/tests/harness/__snapshots__/json-shape/main.ts.txt +++ b/tests/harness/__snapshots__/json-shape/main.ts.txt @@ -1,6 +1,6 @@ -json-shape/main.ts:5:8 - error SC2001: values of type '{ name: string; deprecated: null; }' cannot be compiled yet (supported: number, string, boolean, arrays, Maps, Sets, RegExp, read-only Date values, functions, classes, records, unions of those, and 'unknown') +json-shape/main.ts:5:8 - error SC1110: comptime evaluation failed: expected 'number' at $.limit, got Infinity (only finite numbers can be written as literals) - 4 | * engine) and uses of the binding poison. */ + 4 | * field bakes as null.) */ 5 | import bad from "./nulled.json" with { type: "json" }; | ^~~ 6 | From 45431aec320c53e12e9b664e80d8d48ffbb4f547 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 02:01:48 -0500 Subject: [PATCH 4/5] Choose comptime union arms by discriminant and refuse recursive records A comptime record value whose union has a discriminant takes the arm its discriminant field names, so a value that fits two arms with overlapping shapes gets that arm's tag and layout instead of the first arm it fits. comptimeBakeable reports a record type reachable from its own fields as not bakeable instead of recursing without end, so such a comptime result reports SC1090, and the message names unions and that limit. --- .../src/frontend/lowering/lower-comptime.ts | 54 ++++++++++++++----- tests/corpus/1052-comptime-records.ts | 17 ++++++ tests/diagnostics/comptime.ts | 7 +++ tests/harness/__snapshots__/comptime.ts.txt | 9 +++- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index 31fa5afe02..bdd020127f 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -76,7 +76,7 @@ export function lowerComptime(lowerer: Lowerer, expr: ts.CallExpression): IrExpr "SC1090", expr, `baking comptime results of type '${lowerer.fmt(target)}' into the binary ` + - `(only number, string, boolean, arrays, and records lower to literals)`, + `(number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not)`, ); } // 5. The host executes the extracted callback under a finite budget. @@ -112,25 +112,36 @@ function comptimeFailureDetail(error: unknown): string { * expression: number/string/boolean, arrays and record shapes of those, * and unions of those with null or undefined (the mixed rows of a JSON * table: `(string | number)[][]`), nested. Closures, class instances, - * promises, dyn, and void cannot be baked. */ + * promises, dyn, void, and recursive record types (a record reachable + * from its own fields) cannot be baked. */ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { + return bakeableWithin(lowerer, t, new Set()); +} + +/** comptimeBakeable below the records in `open`, whose fields are being + * checked: reaching one of them again means the type is recursive. */ +function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set): boolean { switch (t.kind) { case "f64": case "string": case "bool": return true; case "array": - return lowerer.comptimeBakeable(t.elem); + return bakeableWithin(lowerer, t.elem, open); case "record": { const shape = lowerer.shapes.get(t.shapeId); - return !!shape && shape.fields.every((f) => lowerer.comptimeBakeable(f.type)); + if (!shape || open.has(t.shapeId)) return false; + open.add(t.shapeId); + const bakeable = shape.fields.every((f) => bakeableWithin(lowerer, f.type, open)); + open.delete(t.shapeId); + return bakeable; } case "union": { const arms = lowerer.unions.get(t.unionId)?.arms; return ( arms !== undefined && arms.every( - (arm) => isUnitType(arm) || (arm.kind !== "union" && comptimeBakeable(lowerer, arm)), + (arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)), ) ); } @@ -139,6 +150,30 @@ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { } } +/** The tag of the arm of union `unionId` that `value` bakes into, or -1. + * A record value whose discriminant field holds one of the union's + * discriminant values takes that case's arm, since arms with overlapping + * shapes can both fit it. Any other value takes the first arm it fits, in + * the union's arm order: a JSON value fits exactly one scalar arm, and + * array or record arms are told apart by their contents. */ +function comptimeUnionArm(lowerer: Lowerer, unionId: string, value: unknown): number { + const def = lowerer.unions.get(unionId); + if (!def) return -1; + const discriminant = def.discriminant; + if ( + discriminant !== undefined && + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + const key = (value as Record)[discriminant.field]; + const named = discriminant.cases.find((c) => c.values.some((v) => v === key)); + if (named !== undefined) + return comptimeFits(lowerer, value, def.arms[named.tag]!) ? named.tag : -1; + } + return def.arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); +} + /** Whether `value` bakes into the bakeable type `t` without a mismatch: * the arm choice of a union literal. Records are exact, as in * comptimeValueToIr; an absent field reads as undefined. */ @@ -168,9 +203,7 @@ function comptimeFits(lowerer: Lowerer, value: unknown, t: IrType): boolean { ); } case "union": - return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => - comptimeFits(lowerer, value, arm), - ); + return comptimeUnionArm(lowerer, t.unionId, value) >= 0; default: return false; } @@ -313,11 +346,8 @@ export function comptimeValueToIr( return { kind: "recordLit", fields, type: expected, loc }; } case "union": { - // The first arm the value fits, in the union's arm order: a JSON - // value fits exactly one scalar arm, and array or record arms are - // told apart by their contents. const arms = lowerer.unions.get(expected.unionId)!.arms; // bakeable-checked - const tag = arms.findIndex((arm) => comptimeFits(lowerer, value, arm)); + const tag = comptimeUnionArm(lowerer, expected.unionId, value); if (tag < 0) fail(describeComptimeValue(value)); const arm = arms[tag]!; const payload: IrExpr = isUnitType(arm) diff --git a/tests/corpus/1052-comptime-records.ts b/tests/corpus/1052-comptime-records.ts index 47cccf7b32..658aaed32a 100644 --- a/tests/corpus/1052-comptime-records.ts +++ b/tests/corpus/1052-comptime-records.ts @@ -43,3 +43,20 @@ const meta = comptime(() => { return { kind: "meta", tags, count: tags.length }; }); console.log(meta.kind, meta.tags.join("|"), meta.count); + +// A discriminated union whose arms both fit a "labeled" value without a +// label: the discriminant picks the arm. +type Item = { kind: "plain"; v: number } | { kind: "labeled"; v: number; label?: string }; +const items: Item[] = comptime( + () => + [ + { kind: "plain", v: 1 }, + { kind: "labeled", v: 2 }, + { kind: "labeled", v: 3, label: "x" }, + ] as Item[], +); +for (const item of items) { + if (item.kind === "labeled") console.log("labeled", item.v, item.label); + else console.log("plain", item.v); +} +console.log(JSON.stringify(items)); diff --git a/tests/diagnostics/comptime.ts b/tests/diagnostics/comptime.ts index 9ba882aafd..9c82394643 100644 --- a/tests/diagnostics/comptime.ts +++ b/tests/diagnostics/comptime.ts @@ -20,3 +20,10 @@ const nested = comptime(() => comptime(() => 1)); const eventually = comptime(async () => 1); const lied = comptime(() => 1 as unknown as string); + +// A record type reachable from its own fields has no literal form. +interface Chain { + value: number; + next: Chain | null; +} +const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); diff --git a/tests/harness/__snapshots__/comptime.ts.txt b/tests/harness/__snapshots__/comptime.ts.txt index 2b1411ae5d..603c297a83 100644 --- a/tests/harness/__snapshots__/comptime.ts.txt +++ b/tests/harness/__snapshots__/comptime.ts.txt @@ -45,4 +45,11 @@ comptime.ts:22:23 - error SC1110: comptime evaluation failed: expected 'string' 21 | 22 | const lied = comptime(() => 1 as unknown as string); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 23 | \ No newline at end of file + 23 | + +comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not) is not supported yet + + 28 | } + 29 | const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 30 | \ No newline at end of file From 437dc1163f77466486a63d759c57d15b70077b8e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 20 Oct 2026 11:39:23 -0500 Subject: [PATCH 5/5] Refuse recursive unions in comptime baking The bakeability check tracks the unions it is inside as well as the records, so a union reachable from itself through an array (`type Tree = number | Tree[]`) reports SC1090 instead of recursing without end. The message names both recursive records and recursive unions. --- .../src/frontend/lowering/lower-comptime.ts | 28 +++++++++++-------- tests/diagnostics/comptime.ts | 4 +++ tests/harness/__snapshots__/comptime.ts.txt | 11 ++++++-- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-comptime.ts b/packages/compiler/src/frontend/lowering/lower-comptime.ts index bdd020127f..f239eea826 100644 --- a/packages/compiler/src/frontend/lowering/lower-comptime.ts +++ b/packages/compiler/src/frontend/lowering/lower-comptime.ts @@ -76,7 +76,7 @@ export function lowerComptime(lowerer: Lowerer, expr: ts.CallExpression): IrExpr "SC1090", expr, `baking comptime results of type '${lowerer.fmt(target)}' into the binary ` + - `(number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not)`, + `(number, string, boolean, and arrays, records and unions of those lower to literals; a record or union type reachable from itself does not)`, ); } // 5. The host executes the extracted callback under a finite budget. @@ -112,13 +112,14 @@ function comptimeFailureDetail(error: unknown): string { * expression: number/string/boolean, arrays and record shapes of those, * and unions of those with null or undefined (the mixed rows of a JSON * table: `(string | number)[][]`), nested. Closures, class instances, - * promises, dyn, void, and recursive record types (a record reachable - * from its own fields) cannot be baked. */ + * promises, dyn, void, and recursive types (a record or union reachable + * from itself, directly or through arrays) cannot be baked. */ export function comptimeBakeable(lowerer: Lowerer, t: IrType): boolean { return bakeableWithin(lowerer, t, new Set()); } -/** comptimeBakeable below the records in `open`, whose fields are being +/** comptimeBakeable below the records and unions in `open` (keyed + * `record:` and `union:`), whose members are being * checked: reaching one of them again means the type is recursive. */ function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set): boolean { switch (t.kind) { @@ -130,20 +131,23 @@ function bakeableWithin(lowerer: Lowerer, t: IrType, open: Set): boolean return bakeableWithin(lowerer, t.elem, open); case "record": { const shape = lowerer.shapes.get(t.shapeId); - if (!shape || open.has(t.shapeId)) return false; - open.add(t.shapeId); + const key = `record:${t.shapeId}`; + if (!shape || open.has(key)) return false; + open.add(key); const bakeable = shape.fields.every((f) => bakeableWithin(lowerer, f.type, open)); - open.delete(t.shapeId); + open.delete(key); return bakeable; } case "union": { const arms = lowerer.unions.get(t.unionId)?.arms; - return ( - arms !== undefined && - arms.every( - (arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)), - ) + const key = `union:${t.unionId}`; + if (arms === undefined || open.has(key)) return false; + open.add(key); + const bakeable = arms.every( + (arm) => isUnitType(arm) || (arm.kind !== "union" && bakeableWithin(lowerer, arm, open)), ); + open.delete(key); + return bakeable; } default: return false; diff --git a/tests/diagnostics/comptime.ts b/tests/diagnostics/comptime.ts index 9c82394643..645779ea77 100644 --- a/tests/diagnostics/comptime.ts +++ b/tests/diagnostics/comptime.ts @@ -27,3 +27,7 @@ interface Chain { next: Chain | null; } const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); + +// A union reachable from itself through an array has no literal form either. +type Tree = number | Tree[]; +const tree: Tree = comptime((): Tree => [1, [2, [3]]]); diff --git a/tests/harness/__snapshots__/comptime.ts.txt b/tests/harness/__snapshots__/comptime.ts.txt index 603c297a83..757c30e6ce 100644 --- a/tests/harness/__snapshots__/comptime.ts.txt +++ b/tests/harness/__snapshots__/comptime.ts.txt @@ -47,9 +47,16 @@ comptime.ts:22:23 - error SC1110: comptime evaluation failed: expected 'string' | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ 23 | -comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record type reachable from its own fields does not) is not supported yet +comptime.ts:29:22 - error SC1090: baking comptime results of type '{ next: null | ...; value: number }' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record or union type reachable from itself does not) is not supported yet 28 | } 29 | const chain: Chain = comptime(() => ({ value: 1, next: null }) as Chain); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 30 | \ No newline at end of file + 30 | + +comptime.ts:33:20 - error SC1090: baking comptime results of type '(...)[] | number' into the binary (number, string, boolean, and arrays, records and unions of those lower to literals; a record or union type reachable from itself does not) is not supported yet + + 32 | type Tree = number | Tree[]; + 33 | const tree: Tree = comptime((): Tree => [1, [2, [3]]]); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 34 | \ No newline at end of file