From 677710075ac5bf6a6ee99c8a6d2c97bb2716a6db Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 21:27:04 -0700 Subject: [PATCH] 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 }