From ac6b5ebe9715ff0f86a0da59ca8a1c71928f8fc0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 03:08:21 -0700 Subject: [PATCH] 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 --- .../src/frontend/lowering/lower-calls.ts | 31 +++++++++++++++++++ tests/corpus/6404-error-in-and-assign.ts | 25 +++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 6862d2d52..c51b75b03 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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 diff --git a/tests/corpus/6404-error-in-and-assign.ts b/tests/corpus/6404-error-in-and-assign.ts index fc6f470e6..a9bb6d086 100644 --- a/tests/corpus/6404-error-in-and-assign.ts +++ b/tests/corpus/6404-error-in-and-assign.ts @@ -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); + } +}