Throw a record extended by Object.assign as a checked-dynamic copy

`throw Object.assign(failure, { bail: true })`, where the record's type
lacks the source's member, used to fall to the Object.assign fence: a
fixed record cannot grow a field. As the operand of a throw, the call now
copies the target into a checked-dynamic object and adds the source's
members. The copy is unobservable, because control leaves with the throw
and nothing on that path reads the target again. Paths that already
lowered run first, so no compiled program changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-20 03:24:23 -07:00
co-authored by Claude Opus 5.5
parent fa2dad9051
commit ac6b5ebe97
2 changed files with 56 additions and 0 deletions
@@ -7601,6 +7601,37 @@ function lowerObjectStaticCall(
}
}
}
// `throw Object.assign(record, { extra })`: an error-shaped record given
// a member its type lacks, thrown at once. A fixed record cannot grow
// the member, so the throw carries a checked-dynamic copy of the target
// with the source's members added. The copy is unobservable: control
// leaves with the throw, so nothing on that path reads the target again.
{
let parent: ts.Node | undefined = call.parent;
while (parent && ts.isParenthesizedExpression(parent)) parent = parent.parent;
const argNodes = call.arguments.nodes;
if (
parent &&
ts.isThrowStatement(parent) &&
argNodes.length === 2 &&
!argNodes.some((a) => ts.isSpreadElement(a))
) {
const tProbe = tryLowerExpression(lowerer, argNodes[0]!);
const sProbe = tryLowerExpression(lowerer, argNodes[1]!);
if (
tProbe?.type.kind === "record" &&
lowerer.dynConvertible(tProbe.type) &&
sProbe &&
(sProbe.type.kind === "dyn" || lowerer.dynConvertible(sProbe.type))
) {
const loc = locOf(call);
const target = lowerer.coerceToExpected(lowerer.lowerExpr(argNodes[0]!), DYN);
const source = lowerer.coerceToExpected(lowerer.lowerExpr(argNodes[1]!), DYN);
if (target.type.kind === "dyn" && source.type.kind === "dyn")
return { kind: "libCall", fn: "dyn.assign", args: [target, source], type: DYN, loc };
}
}
}
// `Object.assign(target, ...sources)` over a CHECKED-DYNAMIC target
// — the n-ary/spread form (`Object.assign({}, ...plugins.map(p =>
// p.options), coreOptions)`, support.js's option-table merge). The
+25
View File
@@ -117,3 +117,28 @@ function failure(message: string, bail: boolean): BuildFailure {
}
for (const f of [failure("stop", true), failure("go", false)])
console.log(f.message, f.bail, f.code);
// A record given a member its type lacks and thrown at once.
interface FailureRecord {
from: string;
isGraceful: boolean;
errorValue: unknown;
}
function classifyFailure(err: unknown, from: string): FailureRecord {
return { from, isGraceful: from === "user", errorValue: err };
}
function handleFailure(err: unknown, from: string): string {
const failure = classifyFailure(err, from);
if (failure.from === "build") {
throw Object.assign(failure, { bail: true });
}
return `${failure.from} ${failure.isGraceful}`;
}
for (const from of ["build", "user"]) {
try {
console.log("handled", handleFailure(new Error("x"), from));
} catch (e) {
const thrownRecord = e as { from: string; bail?: boolean; isGraceful: boolean };
console.log("thrown", thrownRecord.from, thrownRecord.bail, thrownRecord.isGraceful);
}
}