State the empty-array arm rule once

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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 19:11:36 -07:00
co-authored by Claude Opus 5.5
parent 887aa774e0
commit eabcbdb533
3 changed files with 44 additions and 42 deletions
@@ -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
@@ -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
@@ -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,