Throw Node's TypeError when a nullable destructuring source is null (#2537)

This commit is contained in:
Chris Tate
2026-10-29 15:08:26 -05:00
committed by GitHub
4 changed files with 191 additions and 28 deletions
@@ -2549,13 +2549,26 @@ export function lowerDestructuringDecl(
}
}
const out: IrStmt[] = [];
// V8's destructuring TypeError spells STATIC sources: an identifier
// source reads "<name> is not iterable", an identifier-callee call
// "<name> is not a function or its return value is not iterable";
// everything else (property reads, method calls, parameters) gets the
// runtime kind wording — exactly V8's CallPrinter behavior. The DYN
// pattern arm and the unit arms of a narrowed union consume this.
const src = decl.initializer!;
const dynSpell = ts.isIdentifier(src)
? `${src.text} is not iterable`
: ts.isCallExpression(src) && ts.isIdentifier(src.expression)
? `${src.expression.text} is not a function or its return value is not iterable`
: undefined;
// JS: a union source with exactly ONE destructurable arm beside unit
// arms (`nameAndArgs.match(re)` — `RegExpMatchArray | null`, the
// always-matches idiom) destructures through a VALIDATED narrow: the
// unit arms throw at runtime exactly where Node's TypeError would
// (the message is the fence's — a unit value here is the failure
// path either way). TypeScript keeps the fence: narrowing first is
// the annotated fix.
// unit arms throw Node's TypeError, through the checks a dyn source
// takes (RequireObjectCoercible for an object pattern, GetIterator for
// an array pattern). An object pattern whose source has no V8
// spelling keeps the fence. TypeScript keeps the fence: narrowing
// first is the annotated fix.
if (isJsSourceFile(ts.getSourceFileOfNode(decl)) && init.type.kind === "union") {
const def = lowerer.unions.get(init.type.unionId);
const dataArms = (def?.arms ?? []).filter((a) => !isUnitType(a));
@@ -2568,9 +2581,61 @@ export function lowerDestructuringDecl(
(arm.kind === "array" || arm.kind === "record")
) {
const tag = def.arms.indexOf(arm);
const unionId = init.type.unionId;
const utmp = lowerer.declareHiddenLocal("%destrU", init.type);
out.push({ kind: "varDecl", localId: utmp.id, init, loc });
const uref: IrExpr = { kind: "varRef", localId: utmp.id, type: init.type, loc };
// The failure branch holds one of the unit arms: the check reads it
// as the dyn null or undefined it is, so the data arm needs no dyn
// form (a record holding a generator has none).
const unitValue = (): IrExpr => {
const unitOf = (t: IrType): IrExpr =>
lowerer.coerceToExpected(
{ kind: "unitLit", unit: t.kind === "nullT" ? "null" : "undefined", type: t, loc },
DYN,
);
const units = def.arms.filter(isUnitType);
const last = units[units.length - 1]!;
return units.slice(0, -1).reduceRight<IrExpr>(
(rest, t) => ({
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId,
tag: def.arms.indexOf(t),
negated: false,
value: uref,
type: BOOL,
loc,
},
then: unitOf(t),
else_: rest,
type: DYN,
loc,
}),
unitOf(last),
);
};
const spelling = destrSpellingOf(src);
const check: IrExpr | null = ts.isArrayBindingPattern(decl.name)
? {
kind: "dynIterN",
value: unitValue(),
count: decl.name.elements.nodes.length,
...(dynSpell ? { notIterableMessage: dynSpell } : {}),
type: DYN,
loc,
}
: spelling !== null
? {
kind: "dynDestrCheck",
value: unitValue(),
spelling,
...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern),
type: DYN,
loc,
}
: null;
out.push({
kind: "if",
cond: {
@@ -2583,6 +2648,7 @@ export function lowerDestructuringDecl(
loc,
},
then: [
...(check !== null ? [{ kind: "exprStmt" as const, expr: check, loc }] : []),
{
kind: "runtimeFence",
code: "SC1031",
@@ -2635,42 +2701,19 @@ export function lowerDestructuringDecl(
"destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call)",
);
}
const first = (decl.name as ts.ObjectBindingPattern).elements.nodes[0];
const firstKey =
first === undefined || first.dotDotDotToken !== undefined
? undefined
: (first.propertyName ?? first.name);
// V8 names the first property when its key is an identifier, a string
// or a number; a computed first key takes the bare (source-only) form.
const firstProp =
firstKey === undefined || ts.isComputedPropertyName(firstKey)
? undefined
: (patternKeyNameOf(lowerer, firstKey as ts.PropertyName) ?? undefined);
out.push({
kind: "exprStmt",
expr: {
kind: "dynDestrCheck",
value: { kind: "varRef", localId: tmp.id, type: init.type, loc },
spelling,
...(firstProp !== undefined ? { firstProp } : {}),
...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern),
type: init.type,
loc,
},
loc,
});
}
// V8's destructuring TypeError spells STATIC sources: an identifier
// source reads "<name> is not iterable", an identifier-callee call
// "<name> is not a function or its return value is not iterable";
// everything else (property reads, method calls, parameters) gets the
// runtime kind wording — exactly V8's CallPrinter behavior. Only the
// DYN pattern arm consumes this.
const src = decl.initializer!;
const dynSpell = ts.isIdentifier(src)
? `${src.text} is not iterable`
: ts.isCallExpression(src) && ts.isIdentifier(src.expression)
? `${src.expression.text} is not a function or its return value is not iterable`
: undefined;
lowerer.lowerBindingPattern(
decl.name as ts.ArrayBindingPattern | ts.ObjectBindingPattern,
() => ({ kind: "varRef", localId: tmp.id, type: init.type, loc }),
@@ -9589,6 +9632,26 @@ export function lowerDestructuringAssign(
return { kind: "block", body: parts.stmts, loc };
}
/** The first property V8 names in an object pattern's destructuring
* TypeError ("Cannot destructure property 'a' of ..."): an identifier,
* string or numeric first key. A computed first key, a rest element or
* an empty pattern takes the bare (source-only) form. */
function firstPatternProp(
lowerer: Lowerer,
pattern: ts.ObjectBindingPattern,
): { firstProp?: string } {
const first = pattern.elements.nodes[0];
const firstKey =
first === undefined || first.dotDotDotToken !== undefined
? undefined
: (first.propertyName ?? first.name);
const firstProp =
firstKey === undefined || ts.isComputedPropertyName(firstKey)
? undefined
: (patternKeyNameOf(lowerer, firstKey as ts.PropertyName) ?? undefined);
return firstProp !== undefined ? { firstProp } : {};
}
/** V8's spelling of a destructuring RHS in its TypeError message
* ("Cannot destructure 'a' as it is undefined.") — plain identifiers
* spell their name (parens unwrap), `this`, property chains and
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/destructure-nullable-union-source.js"
],
"diags": []
}
@@ -0,0 +1,93 @@
// A destructuring declaration whose source is one object or array type or
// null/undefined (`s.match(re)`, `items.find(...)`) throws Node's TypeError
// when the source is null or undefined, with V8's message for the source
// spelling, and binds normally otherwise.
function show(label, fn) {
try {
fn();
} catch (error) {
console.log(label, error instanceof TypeError, error.message);
}
}
const text = "abc";
show("object-call", () => {
const { index } = text.match(/z/);
console.log("never", index);
});
show("object-empty", () => {
const {} = text.match(/z/);
console.log("never");
});
show("object-identifier", () => {
const found = "abc".match(/z/);
const { index, input } = found;
console.log("never", index, input);
});
show("object-string-key", () => {
const found = "abc".match(/z/);
const { "index": at } = found;
console.log("never", at);
});
show("array-method-call", () => {
const [whole] = "abc".match(/z/);
console.log("never", whole);
});
show("array-identifier", () => {
const found = "abc".match(/z/);
const [whole, group] = found;
console.log("never", whole, group);
});
const items = [
{ name: "a", size: 1 },
{ name: "b", size: 2 },
];
show("object-undefined", () => {
const { name } = items.find((item) => item.size > 5);
console.log("never", name);
});
function firstMatch(text) {
return text.match(/q/);
}
show("array-function-call", () => {
const [first] = firstMatch("abc");
console.log("never", first);
});
// A source that may be null or undefined, and data arms holding a
// generator, which has no untyped form.
function* count() {
yield 1;
yield 2;
}
function pick(kind) {
if (kind === 0) return null;
if (kind === 1) return undefined;
return { counter: count(), label: "picked" };
}
function counters(present) {
return present ? [count(), count()] : null;
}
show("object-null-arm", () => {
const { counter } = pick(0);
console.log("never", counter);
});
show("object-undefined-arm", () => {
const { label } = pick(1);
console.log("never", label);
});
show("array-generator-elements", () => {
const [first] = counters(false);
console.log("never", first);
});
const { counter, label } = pick(2);
const [left, right] = counters(true);
console.log(label, [...counter].join(","), [...left, ...right].join(","));
// Sources that hold a value bind as before.
const { index } = "abc".match(/b/);
const [whole] = "abc".match(/c/);
const { name, size } = items.find((item) => item.size > 1);
console.log("bound", index, whole, name, size);
@@ -0,0 +1 @@
{ "plain": 2635, "san": 3894 }