From 677710075ac5bf6a6ee99c8a6d2c97bb2716a6db Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:27:04 -0700 Subject: [PATCH 1/7] Let an empty `??` array default adopt the left's array arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `''.match(re) ?? []` typed the literal uninhabited (`never[]`), whose element still mapped through never's f64 value representation, so the whole expression was `RegExpMatchArray | number[]` — a result no single array slot can receive. A union return such as `"all" | readonly string[]` refused with SC2003 "union conversion has no unambiguous layout mapping" (`getScriptReach` in a coding agent's tool-discovery path). An EMPTY array-literal default has no element of its own: it adopts the left's present array arm, exactly the rule the union slot and the ternary arm already state for an empty literal. The left's nullish test and the lazy default are unchanged, and a default whose own type already matches the left keeps the identical lowering it had. Corpus: tests/corpus/nullish-empty-array-default.ts pins the union return, string element reads through the default, the declared optional-array slot with a fresh empty value per evaluation, and a parenthesized/template default. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/expressions/binary.ts | 30 ++++++++++++++ .../nullish-empty-array-default.ts.json | 6 +++ tests/corpus/nullish-empty-array-default.ts | 40 +++++++++++++++++++ .../nullish-empty-array-default.ts.json | 1 + 4 files changed, 77 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json create mode 100644 tests/corpus/nullish-empty-array-default.ts create mode 100644 tests/harness/corpus-costs/nullish-empty-array-default.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 48ea9c490..a6d7ad674 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -380,6 +380,19 @@ function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boole }); } +/** An empty `[]` literal whose own type is `never[]` (no contextual array + * type supplied one): the `??` default of an optional array read. */ +function emptyNeverArrayLiteral(lowerer: Lowerer, node: ts.Expression): boolean { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return false; + const own = lowerer.typeOf(value); + return ( + lowerer.checker.isArrayType(own) && + (lowerer.checker.getTypeArguments(own as ts.TypeReference)[0]!.flags & ts.TypeFlags.Never) !== 0 + ); +} + export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -431,6 +444,23 @@ export function lowerNullishPair( const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); return { kind: "nullish", left, right, type: rest[0]!, loc }; } + // An EMPTY array-literal default (`''.match(re) ?? []`) has no element of + // its own: tsc infers `never[]`, whose f64-element mapping the whole + // expression keeps as a second array arm (`RegExpMatchArray | number[]`), + // a result no single array slot can receive. It adopts the left's present + // array arm instead — the empty-literal rule the union slot and the + // ternary arm already state — so `?? []` answers the left's array type. + // A JavaScript `[]` is `any[]` and keeps the dynamic path. + if ( + rest.length === 1 && + rest[0]!.kind === "array" && + !isJsSourceFile(ts.getSourceFileOfNode(expr.right)) && + emptyNeverArrayLiteral(lowerer, expr.right) + ) { + const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); + if (typeEquals(right.type, rest[0]!)) + return { kind: "nullish", left, right, type: rest[0]!, loc }; + } let type = lowerer.irTypeOf(expr); // A runtime-optional read can reach the default even when declarations // call the left side present. Preserve a differently typed primitive diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json new file mode 100644 index 000000000..c3bb5cdf7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/nullish-empty-array-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/nullish-empty-array-default.ts" + ], + "diags": [] +} diff --git a/tests/corpus/nullish-empty-array-default.ts b/tests/corpus/nullish-empty-array-default.ts new file mode 100644 index 000000000..49595a1f1 --- /dev/null +++ b/tests/corpus/nullish-empty-array-default.ts @@ -0,0 +1,40 @@ +// `''.match(re) ?? []`: the empty array-literal default of a `??` adopts the +// left's present array arm. tsc gives the literal the uninhabited `never[]` +// element, which the compiler maps through never's f64 value representation +// (`number[]`), so the whole expression used to be `RegExpMatchArray | +// number[]` — a result no single array slot can receive. + +// A union return (`"all" | readonly string[]`) whose array arm is the +// `?? []` result. +function reach(code: unknown): "all" | readonly string[] { + if (typeof code !== "string") return []; + return /\bALL\b/.test(code) ? "all" : (code.match(/\w+/g) ?? []); +} +console.log(JSON.stringify(reach(7))); +console.log(JSON.stringify(reach("ALL"))); +console.log(JSON.stringify(reach("a.b c"))); +console.log(JSON.stringify(reach(""))); + +// The default's element type is the left's: string element reads and a +// map callback over the value compile as strings, and an absent match +// answers the empty array Node does. +const names = "a-b c".match(/[a-z]+/g) ?? []; +console.log(names.map((name) => name.toUpperCase()).join("+")); +const none = "123".match(/[a-z]+/g) ?? []; +console.log(none.length, JSON.stringify(none), names.length); + +// A declared optional array keeps its own type through the same default, +// and the fresh empty array is a value of its own (mutation, identity). +function fill(source: number[] | undefined, with_: number): number[] { + const list = source ?? []; + list.push(with_); + return list; +} +console.log(JSON.stringify(fill(undefined, 1)), JSON.stringify(fill([7, 8], 9))); +const first = fill(undefined, 2); +const second = fill(undefined, 3); +console.log(first !== second, JSON.stringify(first), JSON.stringify(second)); + +// A parenthesized default and one inside a longer expression. +const longest = ("abbbc".match(/b+/g) ?? []).length; +console.log(longest, `${JSON.stringify("xyz".match(/q/g) ?? [])}!`); diff --git a/tests/harness/corpus-costs/nullish-empty-array-default.ts.json b/tests/harness/corpus-costs/nullish-empty-array-default.ts.json new file mode 100644 index 000000000..b1c421307 --- /dev/null +++ b/tests/harness/corpus-costs/nullish-empty-array-default.ts.json @@ -0,0 +1 @@ +{ "plain": 922, "san": 1502 } From b3d69baae82a4ae214689c8373c0f5007d1d6f3b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:27:04 -0700 Subject: [PATCH 2/7] Narrow a stored union to the stdlib slot's arm instead of refusing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A checked-dynamic JSON stringify (any record or array carrying an `unknown` field, so the runtime walker serializes it) answers `string | undefined` in the IR — its live walker can return undefined for a dyn root — while the checker types the call `string`. Reading a string member of that result (`JSON.stringify(toolsAdded).length` in a token estimator) then met the lib-boundary pass, which refused every union at a concrete slot with SC2003 "expected 'string', got 'string | undefined'". The slot's own type IS the site's proven type: tsc approved the call, so the stored union is the declaration's wider one (an absence arm the compiler itself carries, a stale capture, a widened inferred return). Coerce it with coerceToExpected's checked arm rule — the proven arm's payload comes out and any other arm throws the existing catchable TypeError — and keep the refusal for a union that does not carry the slot type as an arm. No compiling program changes IR: every program on this path refused before. Corpus: tests/corpus/lib-slot-union-arm-extraction.ts pins string members (length, slice, toUpperCase, indexOf, template interpolation, comparison and concatenation) of dyn-root JSON stringify results over arrays, nested records and empty collections. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lib-boundary.ts | 13 ++++++++ .../lib-slot-union-arm-extraction.ts.json | 6 ++++ tests/corpus/lib-slot-union-arm-extraction.ts | 33 +++++++++++++++++++ .../lib-slot-union-arm-extraction.ts.json | 1 + 4 files changed, 53 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json create mode 100644 tests/corpus/lib-slot-union-arm-extraction.ts create mode 100644 tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json diff --git a/packages/compiler/src/frontend/lowering/lib-boundary.ts b/packages/compiler/src/frontend/lowering/lib-boundary.ts index 8a264d346..06213531e 100644 --- a/packages/compiler/src/frontend/lowering/lib-boundary.ts +++ b/packages/compiler/src/frontend/lowering/lib-boundary.ts @@ -122,6 +122,19 @@ function coerceSlot(lowerer: Lowerer, arg: IrExpr, want: IrType, what: string): ); } if (arg.type.kind === "union" && want.kind !== "union" && !containsDynOrUnit(want)) { + // A stored union at a slot whose signature names one concrete type: the + // checker approved the call, so the SITE's type is that slot type and the + // stored union is the declaration's wider one — an absence arm the + // compiler itself carries (a checked-dynamic JSON stringify's + // `string | undefined`), a stale capture, a widened inferred return. + // That is coerceToExpected's checked ARM rule, not a refusal to lay the + // union out: the proven arm's payload comes out, every other arm throws + // the existing catchable TypeError (divergence 38 — sound narrowing + // never reaches them). A union with no such arm keeps the refusal. + if (lowerer.armTag(arg.type.unionId, want) >= 0) { + const narrowed = lowerer.coerceToExpected(arg, want); + if (typeEquals(narrowed.type, want)) return narrowed; + } lowerer.pushDiag(unionMismatchDiag(lowerer.fmt(want), lowerer.fmt(arg.type), arg.loc)); throw new PoisonError(); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json new file mode 100644 index 000000000..be3905a1b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/lib-slot-union-arm-extraction.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/lib-slot-union-arm-extraction.ts" + ], + "diags": [] +} diff --git a/tests/corpus/lib-slot-union-arm-extraction.ts b/tests/corpus/lib-slot-union-arm-extraction.ts new file mode 100644 index 000000000..9e4960fc3 --- /dev/null +++ b/tests/corpus/lib-slot-union-arm-extraction.ts @@ -0,0 +1,33 @@ +// A checked-dynamic JSON stringify — a record or array carrying an `unknown` +// field — answers `string | undefined` in the IR (the runtime walker can +// return undefined for a dyn root) while the checker types the call `string`. +// Reading a string member of that result is the checked ARM extraction at the +// stdlib slot: the proven arm's payload comes out, and the absent arm would +// throw the catchable TypeError instead of the call being refused. + +interface Tool { + name: string; + parameters: unknown; +} + +function sizes(tools: Tool[], extra: { note: string; value: unknown }): string { + const text = JSON.stringify(tools); + let chars = 0; + chars += text.length; + if (tools.length > 0) chars += JSON.stringify(tools[0]).length; + chars += JSON.stringify(extra).slice(0, 24).length; + chars += JSON.stringify({ tools }).toUpperCase().slice(0, 12).length; + chars += JSON.stringify(tools).indexOf('"name"'); + chars += `raw:${JSON.stringify(extra)}`.length > 0 ? 1 : 0; + return `${chars} ${text.length} ${JSON.stringify({ tools }).slice(0, 12)}`; +} + +console.log(sizes([{ name: "alpha", parameters: { a: 1 } }], { note: "n", value: null })); +console.log(sizes([{ name: "beta", parameters: undefined }], { note: "empty", value: [1, 2] })); +console.log(sizes([], { note: "none", value: { nested: { deep: true } } })); +console.log(sizes([{ name: "gamma", parameters: "s" }], { note: "deep", value: { a: [1, { b: 2 }] } })); + +// The same string result feeding a comparison and a concatenation. +const one = JSON.stringify({ name: "x", parameters: 1 }); +const two = JSON.stringify({ parameters: 1, name: "x" }); +console.log(one === two, one < two, ("p" + one).length); diff --git a/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json b/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json new file mode 100644 index 000000000..9316b6552 --- /dev/null +++ b/tests/harness/corpus-costs/lib-slot-union-arm-extraction.ts.json @@ -0,0 +1 @@ +{ "plain": 1517, "san": 2343 } From c294d129d057a433ad00a2f37055eb7a205f581d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:00:52 -0700 Subject: [PATCH 3/7] Filter a union of arrays arm by arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.filter` on `A[] | B[]` refused with SC2020: `lowerArrayUnionHofCall` admitted only `map` and `forEach`. Under TS 5.2's callable-union rule the predicate runs over `A | B`, and the result element is the one the predicate names — `(b): b is TextContent` over a union of block arrays answers `TextContent[]` — which no single arm's element need equal. `filter` joins the same arm dispatch: each arm filters its own concrete array through the existing per-arm helper, and its survivors then pass through the checked conversion to the checker's result element, so a predicate that kept an element it does not describe throws instead of reinterpreting a value. An arm with no such conversion (a boolean predicate that names no narrower element) leaves the SC2020 fence in place. Typed programs are untouched: the gate only widens, and `map`/`forEach` keep their exact helper key, so the helpers a program already emitted stay byte-identical. Co-Authored-By: Claude Opus 5.5 --- .../lowering/containers/array-callbacks.ts | 100 +++++++++++++++--- .../corpus/8530-union-array-filter.ts.json | 6 ++ .../test/union-array-filter-emission.test.ts | 90 ++++++++++++++++ tests/corpus/8530-union-array-filter.ts | 86 +++++++++++++++ .../8530-union-array-filter.ts.json | 1 + 5 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json create mode 100644 packages/compiler/test/union-array-filter-emission.test.ts create mode 100644 tests/corpus/8530-union-array-filter.ts create mode 100644 tests/harness/corpus-costs/8530-union-array-filter.ts.json diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index d4183d5e5..ff4d9efaf 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -632,7 +632,14 @@ function arrayHofHelper( * original concrete array arm — never a copied `(T | U)[]` — and an * arm-specific function adapter wraps the element and receiver arguments * into the union-wide callback ABI. That preserves sparse-array reads, - * callback mutation, and the callback's third-argument identity. */ + * callback mutation, and the callback's third-argument identity. + * + * `filter` joins them: the kept elements are the checker's result element + * (`(b): b is TextContent` over `(TextContent | ImageContent)[] | + * (ThinkingContent | TextContent)[]` answers `TextContent[]`), so each arm + * filters its own array and converts its survivors through the checked + * narrowing — a predicate that kept an element it does not describe throws + * the TypeError. An arm with no such conversion leaves the fence in place. */ export function lowerArrayUnionHofCall( lowerer: Lowerer, call: ts.CallExpression, @@ -640,7 +647,7 @@ export function lowerArrayUnionHofCall( receiverT: IrType & { kind: "union" }, ): IrExpr | null { const method = access.name.text; - if (method !== "map" && method !== "forEach") return null; + if (method !== "map" && method !== "forEach" && method !== "filter") return null; if (!lowerer.isStdlibMember(access)) return null; const def = lowerer.unions.get(receiverT.unionId); if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null; @@ -682,8 +689,45 @@ export function lowerArrayUnionHofCall( lowerer.badType(call, lowerer.typeOf(call)); } if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet); - const outElem = method === "map" ? callbackArrayElement(lowerer, call, fnRet) : VOID; - const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; + if (method === "filter" && !filterPredicateOk(lowerer, fnRet)) { + if (fnRet.kind === "void") { + lowerer.unsupported( + "SC1090", + call, + "'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)", + ); + } + lowerer.badType(call, lowerer.typeOf(call)); + } + // `map` stores the callback's answer; `filter` keeps the elements its + // predicate named. The kept element is the CHECKER's result element, which + // for a written type predicate is narrower than every arm's own: + // `(b): b is TextContent` over `(TextContent | ImageContent)[] | + // (ThinkingContent | TextContent)[]` answers `TextContent[]`. Each arm + // filters its own concrete array and converts its survivors to that + // element through the checked narrowing — a predicate that kept an element + // it does not describe throws instead of misreading it. No conversion the + // arms cannot make honestly means the fence stays. + let outElem: IrType = VOID; + const narrowing: (string | null)[] = []; + if (method === "map") { + outElem = callbackArrayElement(lowerer, call, fnRet); + } else if (method === "filter") { + const result = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (!result || result.kind !== "array") return null; + outElem = result.elem; + const typeText = filterTypeText(lowerer, call); + for (const array of arrays) { + if (typeEquals(array.elem, outElem)) { + narrowing.push(null); + continue; + } + const helper = checkedFilterNarrowing(lowerer, array.elem, outElem, typeText, locOf(call)); + if (helper === null) return null; + narrowing.push(helper); + } + } + const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem); for (const array of arrays) { const armFnT = funcOf( [arrayValueType(lowerer, array.elem), F64, array].slice(0, arity), @@ -700,6 +744,7 @@ export function lowerArrayUnionHofCall( arity, fnRet, outElem, + narrowing, locOf(call), ); return { @@ -716,24 +761,31 @@ export function lowerArrayUnionHofCall( function arrayUnionHofHelper( lowerer: Lowerer, - method: "map" | "forEach", + method: "map" | "forEach" | "filter", receiverT: IrType & { kind: "union" }, arrays: (IrType & { kind: "array" })[], callbackT: IrType & { kind: "func" }, arity: number, fnRet: IrType, outElem: IrType, + narrowing: (string | null)[], loc: SrcLoc, ): string { - const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID; - const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`; + const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem); + // A filter's per-arm conversions are part of what the helper does, so two + // sites that share receiver, callback and result but convert differently + // keep their own helper. `map`/`forEach` never narrow, and leaving their + // key untouched keeps the helpers a program already emitted byte-identical. + const narrowingKey = + method === "filter" ? `:${narrowing.map((helper) => helper ?? "").join(",")}` : ""; + const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}${narrowingKey}`; const existing = lowerer.arrHofHelpers.get(key); if (existing) return existing; const name = keyedHelperName(lowerer, "%arr.union", key); lowerer.arrHofHelpers.set(key, name); const receiver = varRef("a.0", receiverT, loc); const callback = varRef("f.0", callbackT, loc); - const branch = (array: IrType & { kind: "array" }): IrStmt[] => { + const branch = (array: IrType & { kind: "array" }, index: number): IrStmt[] => { const tag = lowerer.armTag(receiverT.unionId, array); const concrete: IrExpr = { kind: "unionNarrow", @@ -751,16 +803,38 @@ function arrayUnionHofHelper( if (!typeEquals(adapted.type, armFnT)) { throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting"); } - const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem); - const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc }; + const callee = arrayHofHelper( + lowerer, + method, + array.elem, + fnRet, + arity, + loc, + method === "filter" ? array.elem : outElem, + ); + const invoke: IrExpr = { + kind: "call", + callee, + args: [concrete, adapted], + type: method === "filter" ? arrayOf(array.elem) : resultT, + loc, + }; + // Survivors of this arm's own array, converted to the checker's result + // element. The helper throws for a kept element the predicate does not + // describe, so a lying predicate never reinterprets a value. + const armNarrow = narrowing[index] ?? null; + const answer: IrExpr = + armNarrow === null + ? invoke + : { kind: "call", callee: armNarrow, args: [invoke], type: resultT, loc }; return resultT.kind === "void" ? [ { kind: "exprStmt", expr: invoke, loc }, { kind: "return", value: null, loc }, ] - : [{ kind: "return", value: invoke, loc }]; + : [{ kind: "return", value: answer, loc }]; }; - let body = branch(arrays[arrays.length - 1]!); + let body = branch(arrays[arrays.length - 1]!, arrays.length - 1); for (let i = arrays.length - 2; i >= 0; i--) { const array = arrays[i]!; body = [ @@ -775,7 +849,7 @@ function arrayUnionHofHelper( type: BOOL, loc, }, - then: branch(array), + then: branch(array, i), else_: body, loc, }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json new file mode 100644 index 000000000..3ebcb0e28 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8530-union-array-filter.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8530-union-array-filter.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-array-filter-emission.test.ts b/packages/compiler/test/union-array-filter-emission.test.ts new file mode 100644 index 000000000..0ff81a032 --- /dev/null +++ b/packages/compiler/test/union-array-filter-emission.test.ts @@ -0,0 +1,90 @@ +// `.filter` on a union of arrays (`A[] | B[]`): the predicate runs over +// `A | B`, and the checker's result element is the arm the predicate names. +// The runtime value is one concrete arm's array, so the lowering must +// dispatch on that arm (unionIsTag), filter the arm's own array, and convert +// the survivors to the named element through the checked narrowing — never +// hand a union receiver to a static array intrinsic. +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +const PRELUDE = [ + "interface TextContent { type: 'text'; text: string }", + "interface ImageContent { type: 'image'; data: string }", + "interface ThinkingContent { type: 'thinking'; thinking: string }", + "type MessageContent =", + " | TextContent[]", + " | (ImageContent | TextContent)[]", + " | (ThinkingContent | TextContent)[];", +]; + +type IrModule = { + entry: string; + functions: { name: string; body: unknown }[]; +}; + +async function compileToIr( + body: string[], +): Promise<{ ok: boolean; module: IrModule | null; diagnostics: string }> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-union-array-filter-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, [...PRELUDE, ...body].join("\n")); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + return { + ok: false, + module: null, + diagnostics: result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"), + }; + const module = deserializeModule(await readFile(outPath, "utf8")) as unknown as IrModule; + expect(validateModule(module as never)).toEqual([]); + return { ok: true, module, diagnostics: "" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function named(module: IrModule, part: string): string { + return JSON.stringify(module.functions.filter((fn) => fn.name.includes(part))); +} + +test("filter on a union of arrays dispatches on the arm", async () => { + const compiled = await compileToIr([ + "function texts(content: MessageContent): string {", + " return content", + " .filter((block): block is TextContent => block.type === 'text')", + " .map((block) => block.text)", + " .join(' ');", + "}", + "const blocks: MessageContent = [{ type: 'text', text: 'a' }];", + "console.log(texts(blocks));", + ]); + expect(compiled.diagnostics).toBe(""); + expect(compiled.ok).toBe(true); + const module = compiled.module!; + // The tag dispatch that reads the runtime arm… + const union = named(module, "%arr.union"); + expect(union).toContain("unionIsTag"); + // …and the checked narrowing each arm's survivors pass through, so a + // predicate that kept an element it does not describe throws. + expect(named(module, "%arr.narrow")).toContain("%narrow.checked"); +}); + +test("filter on a union of arrays keeps the fence without a narrowing", async () => { + // A predicate that names no narrower element answers the union-wide + // element; converting an arm's array to that element is a widening this + // lowering does not build, so the call stays a refusal instead of + // compiling to a different array layout. + const compiled = await compileToIr([ + "function counts(content: MessageContent): number {", + " return content.filter((block): boolean => block.type !== 'image').length;", + "}", + "console.log(counts([]));", + ]); + expect(compiled.ok).toBe(false); + expect(compiled.diagnostics).toContain("SC2020"); +}); diff --git a/tests/corpus/8530-union-array-filter.ts b/tests/corpus/8530-union-array-filter.ts new file mode 100644 index 000000000..0c1f74f93 --- /dev/null +++ b/tests/corpus/8530-union-array-filter.ts @@ -0,0 +1,86 @@ +// `.filter` on a UNION OF ARRAYS (`A[] | B[]`, TS 5.2's callable-union +// rule): the predicate runs over `A | B`, and the checker's result element +// is the one the predicate names — not any arm's own element. The runtime +// value is one concrete arm's array, so the filter dispatches on that arm, +// keeps the arm's survivors in order, and converts each survivor to the +// named element. +interface TextContent { + type: "text"; + text: string; +} +interface ImageContent { + type: "image"; + data: string; +} +interface ThinkingContent { + type: "thinking"; + thinking: string; +} +interface ToolCall { + type: "tool"; + name: string; +} +type MessageContent = + | TextContent[] + | (ImageContent | TextContent)[] + | (ThinkingContent | ToolCall | TextContent)[]; + +function textOf(content: MessageContent): string { + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join(" "); +} + +const contents: MessageContent[] = [ + [{ type: "text", text: "a" }, { type: "text", text: "b" }], + [ + { type: "image", data: "AA" }, + { type: "text", text: "c" }, + ], + [ + { type: "thinking", thinking: "t" }, + { type: "tool", name: "n" }, + { type: "text", text: "d" }, + ], +]; +for (const content of contents) console.log("text", textOf(content)); + +// A predicate written as a function EXPRESSION rather than an arrow: the +// same receiver, the same per-arm dispatch. +function expressions(content: MessageContent): string { + return content + .filter(function (block): block is TextContent { + return block.type === "text"; + }) + .map((block) => block.text) + .join(" "); +} +console.log("expr", expressions(contents[1]!), expressions(contents[2]!)); + +// The predicate sees the union-wide element, so an arm-specific test is +// legal; the survivors still convert per arm. +function longTexts(content: MessageContent): string[] { + return content + .filter((block): block is TextContent => block.type === "text" && block.text !== "c") + .map((block) => block.text); +} +console.log("long", longTexts(contents[1]!).join("+"), longTexts(contents[2]!).join("+")); + +// Node's `filter` reads the length once and visits elements in order; the +// arm dispatch must not change either, and the result is a fresh array. +let seen = 0; +function counted(content: MessageContent): number { + const kept = content.filter((block): block is TextContent => { + seen++; + return block.type === "text"; + }); + return kept.length; +} +console.log("counted", counted(contents[2]!), "seen", seen); +// The empty array is spelled with its arm's type: a bare `[]` literal +// assigned to this three-arm array union is a separate, unrelated gap (the +// literal's `number[]` element has no arm conversion), so it would obscure +// what this program pins. +const empty: MessageContent = [] as TextContent[]; +console.log("empty", textOf(empty), counted(empty), "seen", seen); diff --git a/tests/harness/corpus-costs/8530-union-array-filter.ts.json b/tests/harness/corpus-costs/8530-union-array-filter.ts.json new file mode 100644 index 000000000..b563858c9 --- /dev/null +++ b/tests/harness/corpus-costs/8530-union-array-filter.ts.json @@ -0,0 +1 @@ +{ "plain": 1900, "san": 2200 } From 0c6a6d1db55c7288d2fdc92e715eb2684b7a3e08 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:09:26 -0700 Subject: [PATCH 4/7] Lift a dynamic record field into a union destination A record field the checker types `any`/`unknown` lifts into a non-union destination through a dynCheck, but the union branch of widthLiftPlan searched only for a source arm, and a dyn source has no arm family. SC2002 therefore refused `details: any` into `Details | undefined`, a tool result's `structuredContent: unknown` into a JSON-value union, and the same field carried through a spread into an index-signature union member. The union branch now ends with that destination check: when no arm was selected, a dyn source takes the same canDynCheckTo extraction coerceToExpected already emits for a dyn value in a union slot. It sits after every arm rule, so every conversion that exists today (including a union that has its own dyn arm) keeps that plan unchanged; only the case that returned null before converts now. Co-Authored-By: Claude Opus 5.5 --- .../lowering/coercions/structural-plans.ts | 20 +++++ .../5798-record-field-dyn-into-union.ts.json | 6 ++ .../5798-record-field-dyn-into-union.ts | 85 +++++++++++++++++++ .../5798-record-field-dyn-into-union.ts.json | 1 + 4 files changed, 112 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json create mode 100644 tests/corpus/5798-record-field-dyn-into-union.ts create mode 100644 tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json diff --git a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts index 2a1d75331..8f17827d5 100644 --- a/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts +++ b/packages/compiler/src/frontend/lowering/coercions/structural-plans.ts @@ -160,6 +160,26 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width if (exact.length === 1) selected = exact; else if (exact.length === 0 && lossless.length === 1) selected = lossless; } + // An 'unknown' (dyn) source whose union destination has no dyn arm of + // its own (`details: any` into `Details | undefined`, a tool result's + // `structuredContent: unknown` into the JSON-value union): the checked + // extraction, the same dynCheck coerceToExpected performs for a dyn + // value in a union slot. A dyn source has no arm family, so every arm + // rule above has passed it by, and every plan that exists for a dyn + // source is the one above (its own arm) — this is exactly the case + // that returned null (SC2002) before, so no convertible value changes + // its lowering. + if ( + selected.length !== 1 && + src.kind === "dyn" && + canDynCheckTo( + dst, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) { + return { how: "dynOut" }; + } if (selected.length !== 1) return null; return { how: "liftWrap", tag: selected[0]!.tag, arm: selected[0]!.arm }; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json new file mode 100644 index 000000000..54a7a2931 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5798-record-field-dyn-into-union.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5798-record-field-dyn-into-union.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5798-record-field-dyn-into-union.ts b/tests/corpus/5798-record-field-dyn-into-union.ts new file mode 100644 index 000000000..b273a89ce --- /dev/null +++ b/tests/corpus/5798-record-field-dyn-into-union.ts @@ -0,0 +1,85 @@ +// A record field the checker types `any`/`unknown` flowing into a slot whose +// declared type is a UNION: the checked extraction (dynCheck) is the +// conversion, exactly as for a dyn value in a top-level union slot. Before +// this, such a field only lifted into a NON-union destination, so a tool +// result's `details: unknown` could not reach `Details | undefined`, a +// `structuredContent: unknown` could not reach a JSON-value union, and a +// spread's dynamic key could not reach an index-signature member. +interface Details { + limit: number; + flag?: boolean; +} + +interface Wide { + label: string; + details: any; +} +interface Narrow { + label: string; + details: Details | undefined; +} + +function narrow(w: Wide): Narrow { + return w; +} + +type Json = string | number | boolean | null | { [key: string]: Json } | Json[]; +interface Payload { + text: string; + structuredContent: any; +} +interface JsonPayload { + text: string; + structuredContent: Json | undefined; +} + +function widen(p: Payload): JsonPayload { + return p; +} + +// A spread whose source carries the dynamic field: the same field lift. +interface Built { + content: string; + sections: any; +} +interface Message { + role: string; + content: string; + sections: { [key: string]: string | null } | undefined; + timestamp: number; +} +function fromState(flag: boolean): Built { + return { content: flag ? "on" : "off", sections: { preamble: "p" } }; +} +function message(flag: boolean): Message { + return { role: "system", ...fromState(flag), timestamp: 0 }; +} + +// A callback whose result parameter narrows the dynamic field back to a +// record type adapts into the slot's dynamic-details signature. +interface SlotResult { + content: { type: string; text: string }[]; + details: any; +} +interface ToolResult { + content: { type: string; text: string }[]; + details: { limit: number } | undefined; +} +type Renderer = (result: SlotResult, expanded: boolean) => string; +function format(result: ToolResult, expanded: boolean): string { + return `${result.content.length}:${result.details?.limit ?? "none"}:${expanded}`; +} +const render: Renderer = (result, expanded) => format(result, expanded); + +const wide: Wide = { label: "a", details: { limit: 3 } }; +const n = narrow(wide); +console.log(n.label, n.details === undefined ? "none" : n.details.limit); + +const p = widen({ text: "t", structuredContent: { nested: [1, true, null] } }); +console.log(p.text, JSON.stringify(p.structuredContent ?? null)); + +const m = message(true); +console.log(m.role, m.content, m.timestamp, m.sections?.["preamble"] ?? "-"); + +console.log(render({ content: [{ type: "text", text: "abc" }], details: { limit: 7 } }, true)); +console.log(render({ content: [], details: undefined }, false)); diff --git a/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json b/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json new file mode 100644 index 000000000..4203ad979 --- /dev/null +++ b/tests/harness/corpus-costs/5798-record-field-dyn-into-union.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 } From 830ee3ddf3633b31ebcf00927df2cc4d1dc18762 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 22:09:31 -0700 Subject: [PATCH 5/7] Build a fresh object literal at an async record result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unannotated async method inside a contextually typed object literal is checked against the contextual signature's result, while its own inferred return type stays the returned literal's narrower record. The async return lowered the literal untyped and then coerced that wider record into the declared result, which refused on `content`: the wider content-part union array does not lift into the inferred text-record array. A ToolDefinition execute reached through a Record hit the same SC2002. A fresh object literal returned into a record result is not a thenable, so when the ordinary construction cannot convert it is now built at the result slot, in both the synchronous and the promise-adopting return paths. The field-set comparison also considers the literal's own checker type, not only the contextual type it was checked against. Both the return-path rule and the object-literal rule ask the same question first — does the record the ordinary path builds have a width plan into the result? — so a literal that converts today, and every value that is not a fresh literal, keeps its exact lowering. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 32 ++++++-- .../compiler/src/frontend/lowering/lowerer.ts | 43 ++++++++++- ...9-async-return-record-literal-slot.ts.json | 6 ++ .../5799-async-return-record-literal-slot.ts | 76 +++++++++++++++++++ ...9-async-return-record-literal-slot.ts.json | 1 + 5 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json create mode 100644 tests/corpus/5799-async-return-record-literal-slot.ts create mode 100644 tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 7947f2830..32caedcbf 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1346,20 +1346,38 @@ export function lowerObjectLiteral( ) { const own = lowerer.shapes.get(type.shapeId)!; const target = lowerer.shapes.get(expected.shapeId)!; - if ( - !own.tuple && + // The field set to compare is the LITERAL's: its checker type. The + // contextual type a few lines up can be wider than what the literal + // writes (an unannotated method checked against a generic slot's + // signature: `{ content, details }` under a contextual + // `AgentToolResult` whose optional members the literal never + // mentions), and the destination still names the same fields as the + // literal, so the build can construct them directly. Only when the + // contextual build has no conversion into the destination at all: a + // value that converts today keeps its exact construction. + const ownType = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const ownChecked = + ownType?.kind === "record" && + ownType.shapeId !== type.shapeId && + lowerer.widthLiftPlan(type, expected) === null + ? lowerer.shapes.get(ownType.shapeId) + : undefined; + const fits = (from: IrRecordShape): boolean => + !from.tuple && !target.tuple && - !own.indexValue && + !from.indexValue && !target.indexValue && - !shapeHasAccessorSlots(own) && + !shapeHasAccessorSlots(from) && !shapeHasAccessorSlots(target) && target.fields.every( (field) => - own.fields.some((candidate) => candidate.name === field.name) || + from.fields.some((candidate) => candidate.name === field.name) || (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0), ) && - own.fields.every((field) => target.fields.some((candidate) => candidate.name === field.name)) - ) { + from.fields.every((field) => + target.fields.some((candidate) => candidate.name === field.name), + ); + if (fits(own) || (ownChecked !== undefined && fits(ownChecked))) { type = { kind: "record", shapeId: expected.shapeId }; } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 7700b7406..ad7ca5fc5 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -14204,9 +14204,49 @@ export class Lowerer { if (this.returnsLiteralIntoDyn(node, expected)) return this.lowerExprExpecting(node, expected); if (this.returnsFreshCollection(node) && expected.kind !== "void") return this.lowerExprExpecting(node, expected); + // A fresh object literal into a record result: the same + // destination-directed construction. An unannotated method inside a + // contextually typed literal is checked against the CONTEXTUAL + // signature's result while its own return type stays the literal's + // inferred (narrower) record — `ToolDefinition` + // methods returning `{ content, details }`. Building at the + // contextual type would then coerce that wider record into the + // declared result and refuse; the result slot is the layout. + if (this.returnsFreshRecord(node, expected)) return this.lowerExprExpecting(node, expected); return this.asyncReturnValueOf(node, this.lowerExpr(node), expected); } + /** A fresh object literal returned into a RECORD result whose ordinary + * construction cannot reach that result: not a thenable, so it is built + * at the result type like a synchronous return. An unannotated method + * inside a contextually typed literal is checked against the CONTEXTUAL + * signature's result while its own return type stays the literal's + * inferred (narrower) record — `ToolDefinition` methods + * returning `{ content, details }`. Building at the contextual type + * would then coerce that wider record into the declared result and + * refuse; the result slot is the layout. A literal that declares `then` + * is awaited instead, and a literal whose contextual build does convert + * keeps the ordinary lowering, so no convertible value changes. */ + private returnsFreshRecord(node: ts.Expression, expected: IrType): boolean { + if (expected.kind !== "record") return false; + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isObjectLiteralExpression(e)) return false; + if (this.checker.getPropertyOfType(this.typeOf(e), "then") !== undefined) return false; + // The record the ordinary path builds: the literal's context, with an + // async return's `T | PromiseLike` stripped to the awaited record + // (object-literals.ts does the same before mapping). + let context = this.checker.getContextualType(e) ?? this.typeOf(e); + if ( + context.isUnionType() && + ts.constituentTypes(context).some((t) => ts.symbolNameOf(t.getSymbol()) === "PromiseLike") + ) + context = this.checker.getAwaitedType(context) ?? context; + const built = this.mapTypeOf(context); + if (!built || built.kind !== "record") return false; + return this.widthLiftPlan(built, expected) === null; + } + /** An array literal or a `new Map(...)`, `new Set(...)` or `new * Array(...)` of the builtins: a fresh collection, never a thenable, so * an async return takes the declared result type as its construction @@ -14381,7 +14421,8 @@ export class Lowerer { if ( this.adoptsReturnedPromise() && !this.returnsLiteralIntoDyn(node, expected) && - !this.returnsFreshCollection(node) + !this.returnsFreshCollection(node) && + !this.returnsFreshRecord(node, expected) ) { // The async return lowers its operand untyped first (a promise // adopts before its payload converts); a maybe-promise union needs diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..11487dedc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5799-async-return-record-literal-slot.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5799-async-return-record-literal-slot.ts b/tests/corpus/5799-async-return-record-literal-slot.ts new file mode 100644 index 000000000..fa5824046 --- /dev/null +++ b/tests/corpus/5799-async-return-record-literal-slot.ts @@ -0,0 +1,76 @@ +// A tool definition whose `execute` is an unannotated async method inside a +// contextually typed object literal. tsc checks the method against the +// contextual signature's result (`AgentToolResult`) but INFERS its +// own return type from the returned literal (`{ content: { type, text }[]; +// details: { loaded } }`). The fresh literal must construct at the declared +// result slot, not at the contextual type it was checked against: the +// contextual type's `content` is the wider content-part union array, which +// no copy narrows back into the inferred element record. +interface TextContent { + type: string; + text: string; + textSignature?: string; +} +interface ImageContent { + type: string; + data: string; + mimeType: string; +} +interface Candidate { + name: string; + score: number; +} +interface Definition { + name: string; + search(query: string): Promise; + execute(id: string, query: string): Promise>; +} +interface Result { + content: (TextContent | ImageContent)[]; + details: D; + structuredContent?: unknown; + isError?: boolean; +} +interface Loaded { + loaded: string[]; +} + +function rank(query: string, names: string[]): Candidate[] { + return names.filter((name) => name.includes(query)).map((name) => ({ name, score: name.length })); +} + +function makeDefinition(names: string[]): Definition { + return { + name: "search", + async search(query: string) { + return { loaded: rank(query, names).map((candidate) => candidate.name) }; + }, + async execute(_id: string, query: string) { + return { + content: [{ type: "text", text: rank(query, names).map((c) => c.name).join(",") }], + details: { loaded: rank(query, names).map((c) => c.name) }, + }; + }, + }; +} + +// The same shape through a record of definitions, each with its own details. +type AnyDefinition = Definition; +function makeNamed(names: string[]): Record<"search", AnyDefinition> { + return { search: makeDefinition(names) }; +} + +const definition = makeDefinition(["find-file", "grep-tree", "read-file"]); +definition + .search("file") + .then((loaded) => { + console.log("search:", loaded.loaded.join(",")); + return definition.execute("call-1", "file"); + }) + .then((result) => { + console.log("execute:", result.content.length, result.content[0]!.type, result.details.loaded.join(",")); + return makeNamed(["read-file"]).search.execute("call-2", "file"); + }) + .then((second) => { + console.log("second:", second.content.length, second.details.loaded.join(",")); + }); diff --git a/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json new file mode 100644 index 000000000..4203ad979 --- /dev/null +++ b/tests/harness/corpus-costs/5799-async-return-record-literal-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 1800, "san": 1900 } From 887aa774e0ac689da9c8e956c820f264fe15f582 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:58:39 -0700 Subject: [PATCH 6/7] Build an empty array literal in a union of array slots at its first arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const message: M = { content: [] }` with `content: TextContent[] | ImageContent[]` threw at run time, `a 'number[]' value is not representable in the target union`. tsc types an empty literal `never[]`, whose element has no representation of its own: the literal mapped through never's f64 element as `number[]`, then the union slot's retag found no width lift into either array arm and took the stranded-source trap. An empty array carries no element to select an arm by, and it is the same value whichever array family stores it, so the literal is built as the union's FIRST array arm — the rule a lone array slot already states, and the one the ternary arm and the `?? []` default apply. Both the IR slot (lowerExprExpecting) and the literal's own type mapping (lowerArrayLiteral) now state it for any number of array arms; a single array arm keeps the identical lowering it had. A `never[]` VALUE (an explicitly declared variable) still maps to `number[]` and keeps its refusal: mapping never's element globally is a different change. Corpus: tests/corpus/empty-array-union-slot.ts pins the nested record field, declarations and reassignment, declared and async returns, ternary arms (one and both empty), arguments and default parameters, deeper nesting, a class field, an optional arm, array/Set/Map element slots, a callback argument, an assertion, and a fresh array per evaluation. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/array-literals.ts | 10 +- .../compiler/src/frontend/lowering/lowerer.ts | 9 ++ .../corpus/empty-array-union-slot.ts.json | 6 + .../test/union-array-lowering.test.ts | 22 ++++ tests/corpus/empty-array-union-slot.ts | 106 ++++++++++++++++++ .../empty-array-union-slot.ts.json | 1 + 6 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json create mode 100644 tests/corpus/empty-array-union-slot.ts create mode 100644 tests/harness/corpus-costs/empty-array-union-slot.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 9545df829..29ddf3aac 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -208,14 +208,14 @@ export function lowerArrayLiteral( // []`, the `[]` default argument against `string[] | undefined`) has // no useful own type — tsc infers `never[]`, whose f64-element // mapping is a representation for the uninhabited, not the slot's - // arm. When the contextual union has exactly ONE array arm there is - // no ambiguity: build the literal as that arm and let the slot's - // coercion wrap it. Two array arms would need tsc's own best-fit - // choice — none arises in practice; the fence stands. + // arm. Build the literal as the union's FIRST array arm and let the + // slot's coercion wrap it: an empty array is the same value whichever + // array family stores it, so several array arms raise no ambiguity — + // the rule a lone array slot already states. if (ctxUnion && expr.elements.nodes.length === 0) { const def = lowerer.unions.get(ctxUnion.unionId); const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; - if (arrayArms.length === 1) mapped = arrayArms[0]!; + if (arrayArms.length >= 1) mapped = arrayArms[0]!; } // A NON-EMPTY literal whose own type has no static home (the JS dyn // fallback — a null/dyn mapping, or an inference that degraded to a diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ad7ca5fc5..1fde89a55 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -14044,6 +14044,15 @@ export class Lowerer { ); } } + // An EMPTY literal carries no element to select an arm by: tsc + // infers `never[]`, whose f64-element mapping is the uninhabited + // element's representation, not one of the slot's arms. Every empty + // array is the same value whichever array arm stores it, so the + // literal is built as the union's FIRST array arm — the empty + // literal rule a lone array slot already states, and the same one + // the ternary arm and the `?? []` default apply. + if (!arm && arms.length > 1 && x.elements.nodes.length === 0) + arm = arms.find((candidate) => candidate.kind === "array"); if (arm) { const built = this.lowerArrayLiteral( x, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json new file mode 100644 index 000000000..1e26cfe54 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/empty-array-union-slot.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/empty-array-union-slot.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/union-array-lowering.test.ts b/packages/compiler/test/union-array-lowering.test.ts index 2830c6fdf..09ef0993d 100644 --- a/packages/compiler/test/union-array-lowering.test.ts +++ b/packages/compiler/test/union-array-lowering.test.ts @@ -139,3 +139,25 @@ export {}; if (!result.ok) return; expect(helpers(result.ir, "%union.arr").length).toBeGreaterThan(0); }); + +test("an empty array literal takes the union's first array arm instead of trapping", async () => { + const result = await lower(` +type TextContent = { type: "text"; text: string }; +type ImageContent = { type: "image"; url: string }; +interface Message { + content: TextContent[] | ImageContent[]; +} +const message: Message = { content: [] }; +function lists(): string[] | number[] { + return []; +} +console.log(JSON.stringify(message.content), JSON.stringify(lists())); +export {}; +`); + expect(result.ok).toBe(true); + if (!result.ok) return; + // An empty literal carries no element of its own, so it is built as the + // union's first array arm: no stranded-source trap and no element rebuild. + expect(helpers(result.ir, "%union.strand")).toEqual([]); + expect(helpers(result.ir, "%union.arr")).toEqual([]); +}); diff --git a/tests/corpus/empty-array-union-slot.ts b/tests/corpus/empty-array-union-slot.ts new file mode 100644 index 000000000..ee6809498 --- /dev/null +++ b/tests/corpus/empty-array-union-slot.ts @@ -0,0 +1,106 @@ +// An EMPTY array literal in a union of array slots (`content: TextContent[] | +// ImageContent[]`, `string[] | number[]`). tsc types the literal `never[]`, an +// element with no representation of its own: the literal used to lower as +// never's f64-element array (`number[]`) and the slot's retag threw +// "a 'number[]' value is not representable in the target union". An empty +// array is the same value whichever array family stores it, so the literal is +// built as the union's first array arm — the rule a lone array slot states — +// and every shape below reaches Node's answer. +type TextContent = { type: "text"; text: string }; +type ImageContent = { type: "image"; url: string }; +interface Message { + role: string; + content: TextContent[] | ImageContent[]; +} + +// A nested record field: the reported shape. +const empty: Message = { role: "user", content: [] }; +console.log(JSON.stringify(empty)); + +// A declaration, its initialization and a later reassignment. +let wide: string[] | number[] = []; +console.log(JSON.stringify(wide)); +wide = [1, 2]; +console.log(JSON.stringify(wide)); +wide = []; +console.log(JSON.stringify(wide)); + +// A declared return, a ternary arm, and an async promise's payload. +function none(): string[] | number[] { + return []; +} +console.log(JSON.stringify(none()), none() !== none()); + +function either(take: boolean): string[] | number[] { + return take ? [] : [1, 2]; +} +console.log(JSON.stringify(either(true)), JSON.stringify(either(false))); + +function bothWays(): string[] | number[] { + return false ? [] : []; +} +console.log(JSON.stringify(bothWays())); + +async function settled(): Promise { + return []; +} +void settled().then((value) => console.log("settled", JSON.stringify(value))); + +// An argument slot, and a default parameter that is itself the slot. +function report(label: string, value: string[] | number[]): void { + console.log(label, JSON.stringify(value)); +} +report("argument", []); +report("argument-filled", ["x"]); + +function withDefault(value: string[] | number[] = []): void { + console.log("default", JSON.stringify(value)); +} +withDefault(); +withDefault([3]); + +// Deeper nesting, a class field, and a discriminated record's field. +interface Nested { + inner: { list: readonly string[] | readonly number[]; tag: string }; +} +const nested: Nested = { inner: { list: [], tag: "t" } }; +console.log(JSON.stringify(nested)); + +class Store { + lists: string[] | number[] = []; +} +const store = new Store(); +console.log("class", JSON.stringify(store.lists), store.lists !== undefined); + +// An optional arm (`| undefined`) keeps the empty literal on its array arm. +const maybe: string[] | number[] | undefined = []; +console.log("maybe", maybe !== undefined, JSON.stringify(maybe)); + +// The empty literal as an element of an array, and through collections whose +// element slot is the union. +const rows: (string[] | number[])[] = [[], [1, 2], []]; +console.log("rows", JSON.stringify(rows)); +const grouped = new Set(); +grouped.add([]); +grouped.add(["s"]); +console.log("set", JSON.stringify([...grouped])); +const byKey = new Map(); +byKey.set("a", []); +byKey.set("b", [7]); +console.log("map", JSON.stringify([...byKey.entries()])); + +// A callback argument, and the assertion form of the same value. +function apply(fn: (value: string[] | number[]) => string): string { + return fn([]); +} +console.log("callback", apply((value) => JSON.stringify(value))); +const asserted = { role: "tool", content: [] } as Message; +console.log("asserted", JSON.stringify(asserted)); + +// Every evaluation builds a fresh empty array, as Node does. +function fresh(): string[] | number[] { + return []; +} +const first = fresh(); +const second = fresh(); +console.log("fresh", first !== second, JSON.stringify(first), JSON.stringify(second)); diff --git a/tests/harness/corpus-costs/empty-array-union-slot.ts.json b/tests/harness/corpus-costs/empty-array-union-slot.ts.json new file mode 100644 index 000000000..b7f02e592 --- /dev/null +++ b/tests/harness/corpus-costs/empty-array-union-slot.ts.json @@ -0,0 +1 @@ +{ "plain": 2700, "san": 3400 } From eabcbdb53398709319b7f235824bc3f826fee7ab Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 17:59:41 -0700 Subject: [PATCH 7/7] State the empty-array arm rule once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three sites that give an empty `[]` literal a home — a union slot, a contextual union, and the `??` default — each carried their own copy of the rule and each tested it slightly differently (element count here, a `never[]` own type there), so a later fix could drift one from the others. emptyArrayLiteralArm states it once: an empty literal has no element to select an arm by, so it builds as the destination's first array arm. Every site keeps the lowering it had. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/array-literals.ts | 36 ++++++++++++++----- .../frontend/lowering/expressions/binary.ts | 36 +++++++------------ .../compiler/src/frontend/lowering/lowerer.ts | 14 +++----- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts index 29ddf3aac..aa23fb0a2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/array-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/array-literals.ts @@ -38,6 +38,28 @@ import { arrayValueStore, primitiveElement } from "../array-values.js"; import { classGeneratorIterable } from "../class-iterables.js"; import { iteratorCanStep } from "../iterator-consumption.js"; +/** The arm an EMPTY `[]` literal builds as: `arms`' first array arm, or + * null when the literal is not empty (an element selects the arm) or no + * arm is an array. + * + * An empty literal carries no element of its own — tsc infers `never[]`, + * whose f64-element mapping is the uninhabited element's representation, + * not one of the destination's arms — and every empty array is the same + * value whichever array family stores it, so several array arms raise no + * ambiguity. This is the ONE statement of that rule: a union slot, a + * contextual union, and the `??` default all ask it. A lone array slot + * states the same thing without asking. */ +export function emptyArrayLiteralArm( + node: ts.Expression, + arms: readonly IrType[], +): (IrType & { kind: "array" }) | null { + let value = node; + while (ts.isParenthesizedExpression(value)) value = value.expression; + if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return null; + const arm = arms.find((candidate) => candidate.kind === "array"); + return (arm as (IrType & { kind: "array" }) | undefined) ?? null; +} + /** JavaScript: whether `expr` is or holds an existing checked-dynamic * object — a binding stored as a dyn value whose checker type is an * object, or an array or object literal with such an element. Building @@ -206,16 +228,12 @@ export function lowerArrayLiteral( mapped = arrayOf(unitOnlyUnion(lowerer.unions)); // An EMPTY literal in a union slot (`const t: string[] | undefined = // []`, the `[]` default argument against `string[] | undefined`) has - // no useful own type — tsc infers `never[]`, whose f64-element - // mapping is a representation for the uninhabited, not the slot's - // arm. Build the literal as the union's FIRST array arm and let the - // slot's coercion wrap it: an empty array is the same value whichever - // array family stores it, so several array arms raise no ambiguity — - // the rule a lone array slot already states. - if (ctxUnion && expr.elements.nodes.length === 0) { + // no useful own type: it builds as the union's first array arm (the + // emptyArrayLiteralArm rule) and the slot's coercion wraps it. + if (ctxUnion) { const def = lowerer.unions.get(ctxUnion.unionId); - const arrayArms = def?.arms.filter((a) => a.kind === "array") ?? []; - if (arrayArms.length >= 1) mapped = arrayArms[0]!; + const arm = emptyArrayLiteralArm(expr, def?.arms ?? []); + if (arm) mapped = arm; } // A NON-EMPTY literal whose own type has no static home (the JS dyn // fallback — a null/dyn mapping, or an inference that degraded to a diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index a6d7ad674..c204b40f4 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -73,6 +73,7 @@ import { nsWritableTarget } from "../lower-namespaces.js"; import { arrayValueStore, tryLowerBytesReadNumberEquality } from "../array-values.js"; import { cjsExportMemberWriteGlobalOf, cjsInFileExportGlobalOf } from "../cjs-exports-object.js"; import { lowerUnionFieldWrite } from "./union-fields.js"; +import { emptyArrayLiteralArm } from "./array-literals.js"; import { lowerAbstractEquality } from "../abstract-equality.js"; import { lowerInExpression } from "./relational.js"; import { lowerDynTypeofTest, lowerErrorCodeTypeofTest } from "./typeof.js"; @@ -380,19 +381,6 @@ function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boole }); } -/** An empty `[]` literal whose own type is `never[]` (no contextual array - * type supplied one): the `??` default of an optional array read. */ -function emptyNeverArrayLiteral(lowerer: Lowerer, node: ts.Expression): boolean { - let value = node; - while (ts.isParenthesizedExpression(value)) value = value.expression; - if (!ts.isArrayLiteralExpression(value) || value.elements.nodes.length > 0) return false; - const own = lowerer.typeOf(value); - return ( - lowerer.checker.isArrayType(own) && - (lowerer.checker.getTypeArguments(own as ts.TypeReference)[0]!.flags & ts.TypeFlags.Never) !== 0 - ); -} - export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -445,21 +433,21 @@ export function lowerNullishPair( return { kind: "nullish", left, right, type: rest[0]!, loc }; } // An EMPTY array-literal default (`''.match(re) ?? []`) has no element of - // its own: tsc infers `never[]`, whose f64-element mapping the whole - // expression keeps as a second array arm (`RegExpMatchArray | number[]`), - // a result no single array slot can receive. It adopts the left's present - // array arm instead — the empty-literal rule the union slot and the - // ternary arm already state — so `?? []` answers the left's array type. - // A JavaScript `[]` is `any[]` and keeps the dynamic path. + // its own, so it builds as the left's array arm (the emptyArrayLiteralArm + // rule the union slot and the contextual union share) and the whole + // expression answers the left's array type instead of the checker's + // `never[]`-flavored second arm. A JavaScript `[]` is `any[]` and keeps + // the dynamic path. if ( rest.length === 1 && rest[0]!.kind === "array" && - !isJsSourceFile(ts.getSourceFileOfNode(expr.right)) && - emptyNeverArrayLiteral(lowerer, expr.right) + !isJsSourceFile(ts.getSourceFileOfNode(expr.right)) ) { - const right = lowerer.lowerExprExpecting(expr.right, rest[0]!); - if (typeEquals(right.type, rest[0]!)) - return { kind: "nullish", left, right, type: rest[0]!, loc }; + const arm = emptyArrayLiteralArm(expr.right, rest); + if (arm) { + const right = lowerer.lowerExprExpecting(expr.right, arm); + if (typeEquals(right.type, arm)) return { kind: "nullish", left, right, type: arm, loc }; + } } let type = lowerer.irTypeOf(expr); // A runtime-optional read can reach the default even when declarations diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 1fde89a55..53849353e 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -781,6 +781,7 @@ import { lowerRecordFieldCall, lowerObjectMethodCall } from "./lower-calls.js"; import { fenceCrossBlockNsRef, nsPathPrefix } from "./lower-namespaces.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { partialAssertionFields } from "./partial-assertions.js"; +import { emptyArrayLiteralArm } from "./expressions/array-literals.js"; import { lowerTernary } from "./lower-exprs.js"; import { maybePromiseReturnStmt, @@ -14044,15 +14045,10 @@ export class Lowerer { ); } } - // An EMPTY literal carries no element to select an arm by: tsc - // infers `never[]`, whose f64-element mapping is the uninhabited - // element's representation, not one of the slot's arms. Every empty - // array is the same value whichever array arm stores it, so the - // literal is built as the union's FIRST array arm — the empty - // literal rule a lone array slot already states, and the same one - // the ternary arm and the `?? []` default apply. - if (!arm && arms.length > 1 && x.elements.nodes.length === 0) - arm = arms.find((candidate) => candidate.kind === "array"); + // An EMPTY literal carries no element to select an arm by: it + // builds as the union's first array arm (the emptyArrayLiteralArm + // rule the contextual union and the `?? []` default share). + if (!arm) arm = emptyArrayLiteralArm(x, arms) ?? undefined; if (arm) { const built = this.lowerArrayLiteral( x,