Throw on empty patterns over checked-dynamic sources

An empty binding pattern still performs its one observable step:
RequireObjectCoercible for `{}` and GetIterator for `[]`. Over a statically
non-nullish source that step is invisible, which is why the empty-pattern
lowering emitted nothing; a checked-dynamic source can be null or undefined
at runtime, where V8 throws.

Emit the runtime null/undefined test for dyn sources in the empty-pattern
branch, and let a JS-file empty object pattern take the source-spelling
message dynDestrCheck already produces for non-empty ones.

Test262 (static-adapted-v6, --workers 8), affected chapters before -> after:
language/statements/class/dstr pass 1518 -> 1526, fail 424 -> 416;
language/expressions/class/dstr pass 1518 -> 1526, fail 424 -> 416. No
pass -> fail.

Co-Authored-By: Claude 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 19:51:10 -07:00
co-authored by Claude 5.5
parent 460bd6fb38
commit 34c39e5443
3 changed files with 131 additions and 1 deletions
@@ -2617,7 +2617,7 @@ export function lowerDestructuringDecl(
init.type.kind === "dyn" &&
ts.isObjectBindingPattern(decl.name) &&
isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
startsWithRuntimeKey(lowerer, decl.name) &&
(decl.name.elements.nodes.length === 0 || startsWithRuntimeKey(lowerer, decl.name)) &&
destrSpellingOf(decl.initializer) !== null;
if (
jsRuntimeKeyCheck ||
@@ -3181,6 +3181,39 @@ export function lowerBindingPattern(
// every statically non-nullish kind, record or not (the assignment
// path's rule); possibly-nullish sources would throw in JS and keep
// a fence.
//
// A CHECKED-DYNAMIC source still owes that step at runtime: an empty
// object pattern over null/undefined throws RequireObjectCoercible,
// and an empty array pattern throws GetIterator's "is not iterable".
// Nothing else about the pattern is observable, so the fence is the
// whole lowering.
if (srcType.kind === "dyn") {
const unitLoc = locOf(pattern);
const isObject = ts.isObjectBindingPattern(pattern);
for (const unit of ["null", "undefined"] as const) {
// V8 describes an argument-derived source by its type ("object
// null"); a bare non-empty pattern names its source instead, but
// those reads throw on their own before this check could fire.
const described = unit === "null" ? "object null" : "undefined";
const message = isObject
? `Cannot destructure '${described}' as it is ${unit}.`
: `${described} is not iterable (cannot read property Symbol(Symbol.iterator))`;
out.push({
kind: "if",
cond: { kind: "dynTest", test: unit, value: srcRef(), type: BOOL, loc: unitLoc },
then: [
{
kind: "exprStmt",
expr: nodeThrowExpr(1, "", message, VOID, unitLoc),
loc: unitLoc,
},
],
else_: null,
loc: unitLoc,
});
}
return;
}
if (
isUnitType(srcType) ||
(srcType.kind === "union" &&
@@ -0,0 +1,96 @@
// An empty destructuring pattern still performs its one observable step:
// RequireObjectCoercible for `{}` and GetIterator for `[]`. A checked-dynamic
// source reaches that step at runtime, so null and undefined throw the same
// TypeError Node does while every other value — including primitives, which
// ToObject wraps — is accepted. The throws are printed through
// `instanceof` rather than the message, since V8 spells the source
// differently per position (an argument is "object null", a declaration
// names its expression).
function report(label, fn) {
try {
fn();
console.log(label, "no-throw");
} catch (error) {
console.log(label, error instanceof TypeError ? "TypeError" : "other");
}
}
function acceptsWhatever(value) {
return value;
}
report("object-null", () => {
const source = acceptsWhatever(null);
const {} = source;
});
report("object-undefined", () => {
const source = acceptsWhatever(undefined);
const {} = source;
});
function objectParameter({}) {
return "bound";
}
report("param-null", () => {
objectParameter(null);
});
report("param-undefined", () => {
objectParameter(undefined);
});
report("param-number", () => {
console.log("param-number returned", objectParameter(7));
});
report("param-string", () => {
console.log("param-string returned", objectParameter("text"));
});
function arrayParameter([]) {
return "bound";
}
report("array-param-null", () => {
arrayParameter(null);
});
report("array-param-undefined", () => {
arrayParameter(undefined);
});
function nestedObject({ inner: {} }) {
return "nested";
}
report("nested-null", () => {
nestedObject({ inner: null });
});
report("nested-undefined", () => {
nestedObject({ inner: undefined });
});
report("nested-object", () => {
console.log("nested-object returned", nestedObject({ inner: {} }));
});
// A loop head rebinds the pattern per iteration, exactly like a declaration.
report("for-of-object", () => {
for (const {} of acceptsWhatever([null])) {
console.log("never");
}
});
// Non-nullish primitives are accepted by both pattern kinds.
report("primitives", () => {
const numbers = [0, "", false, 1, "x"];
for (const value of numbers) {
const {} = value;
console.log("destructured", JSON.stringify(value), typeof value);
}
});
// The assignment form keeps its own (already exact) check.
report("assignment-null", () => {
let bound;
({} = bound);
});
report("assignment-number", () => {
let bound = 5;
({} = bound);
console.log("assignment-number finished");
});
@@ -0,0 +1 @@
{ "plain": 2400, "san": 24000 }