From 442c06fde3982b50df6c9b8127e8c465fe73cfd7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 04:20:01 -0500 Subject: [PATCH 1/2] Throw Node's TypeError when a nullable destructuring source is null In JavaScript files, a destructuring declaration whose source is one object or array type or null/undefined (`text.match(re)`, `items.find(...)`) threw a plain Error when the source was null or undefined. It now throws Node's TypeError with V8's message: "Cannot destructure property 'index' of 'text.match(...)' as it is null." for an object pattern, and "found is not iterable" or "object null is not iterable (cannot read property Symbol(Symbol.iterator))" for an array pattern. An object pattern whose source V8 spells in a form the compiler does not reproduce keeps the runtime fence. --- .../src/frontend/lowering/lower-stmts.ts | 87 +++++++++++++------ .../destructure-nullable-union-source.js.json | 6 ++ .../destructure-nullable-union-source.js | 63 ++++++++++++++ .../destructure-nullable-union-source.js.json | 1 + 4 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json create mode 100644 tests/corpus/destructure-nullable-union-source.js create mode 100644 tests/harness/corpus-costs/destructure-nullable-union-source.js.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 377b08917b..05a8cfa2d9 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2549,13 +2549,26 @@ export function lowerDestructuringDecl( } } const out: IrStmt[] = []; + // V8's destructuring TypeError spells STATIC sources: an identifier + // source reads " is not iterable", an identifier-callee call + // " 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)); @@ -2571,6 +2584,26 @@ export function lowerDestructuringDecl( 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 }; + const spelling = destrSpellingOf(src); + const check: IrExpr | null = ts.isArrayBindingPattern(decl.name) + ? { + kind: "dynIterN", + value: lowerer.coerceToExpected(uref, DYN), + count: decl.name.elements.nodes.length, + ...(dynSpell ? { notIterableMessage: dynSpell } : {}), + type: DYN, + loc, + } + : spelling !== null + ? { + kind: "dynDestrCheck", + value: lowerer.coerceToExpected(uref, DYN), + spelling, + ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern), + type: DYN, + loc, + } + : null; out.push({ kind: "if", cond: { @@ -2583,6 +2616,7 @@ export function lowerDestructuringDecl( loc, }, then: [ + ...(check !== null ? [{ kind: "exprStmt" as const, expr: check, loc }] : []), { kind: "runtimeFence", code: "SC1031", @@ -2635,42 +2669,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 " is not iterable", an identifier-callee call - // " 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 +9600,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 diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json new file mode 100644 index 0000000000..f0fff1758a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/destructure-nullable-union-source.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/destructure-nullable-union-source.js" + ], + "diags": [] +} diff --git a/tests/corpus/destructure-nullable-union-source.js b/tests/corpus/destructure-nullable-union-source.js new file mode 100644 index 0000000000..84af858bfe --- /dev/null +++ b/tests/corpus/destructure-nullable-union-source.js @@ -0,0 +1,63 @@ +// 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); +}); + +// 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); diff --git a/tests/harness/corpus-costs/destructure-nullable-union-source.js.json b/tests/harness/corpus-costs/destructure-nullable-union-source.js.json new file mode 100644 index 0000000000..a3fd561222 --- /dev/null +++ b/tests/harness/corpus-costs/destructure-nullable-union-source.js.json @@ -0,0 +1 @@ +{ "plain": 2635, "san": 3894 } From 7aaf98b53c20e6400623bc4c78be98b3ee676b59 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 10:24:12 -0500 Subject: [PATCH 2/2] Check nullable destructuring sources through their unit arm The TypeError check for a null or undefined source now reads the unit arm the value holds as a dyn null or undefined, instead of converting the whole union to dyn. A union whose data arm has no dyn form, such as a record holding a generator, compiles again instead of failing with SC9001. --- .../src/frontend/lowering/lower-stmts.ts | 36 +++++++++++++++++-- .../destructure-nullable-union-source.js | 30 ++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 05a8cfa2d9..5027610026 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2581,14 +2581,46 @@ 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( + (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: lowerer.coerceToExpected(uref, DYN), + value: unitValue(), count: decl.name.elements.nodes.length, ...(dynSpell ? { notIterableMessage: dynSpell } : {}), type: DYN, @@ -2597,7 +2629,7 @@ export function lowerDestructuringDecl( : spelling !== null ? { kind: "dynDestrCheck", - value: lowerer.coerceToExpected(uref, DYN), + value: unitValue(), spelling, ...firstPatternProp(lowerer, decl.name as ts.ObjectBindingPattern), type: DYN, diff --git a/tests/corpus/destructure-nullable-union-source.js b/tests/corpus/destructure-nullable-union-source.js index 84af858bfe..b1d9de7b34 100644 --- a/tests/corpus/destructure-nullable-union-source.js +++ b/tests/corpus/destructure-nullable-union-source.js @@ -56,6 +56,36 @@ show("array-function-call", () => { 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/);