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 }