From 4d417da7b89f196aa7ddf4d63b332fffe7206f09 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 10:22:24 -0500 Subject: [PATCH] Select the discriminated member for asserted object literals An object literal asserted to a discriminated union keeps its own record layout, and that layout can equal another member's layout: in `{ kind: "return", value, loc } as Stmt`, a non-null `value` gives the fields of a `throw` member. The value was wrapped as that member, so a switch on `kind` read it with the `return` member's layout. The compiled program aborted with an invalid union tag or read past the record; the native compiler crashed this way while lowering three.js. The literal's discriminant now selects the member through type assertions too, as it already did through parentheses and `satisfies`. --- .../compiler/src/frontend/lowering/lowerer.ts | 11 ++++- .../6021-asserted-literal-union-arm.ts.json | 6 +++ .../corpus/6021-asserted-literal-union-arm.ts | 42 +++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6021-asserted-literal-union-arm.ts.json create mode 100644 tests/corpus/6021-asserted-literal-union-arm.ts diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index fe998af36..6ae4fa4aa 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -8581,9 +8581,18 @@ export class Lowerer { // initial contextual layout was chosen. Its known discriminator still // selects the destination arm; validate that payload before wrapping. // Work with the already lowered value so property effects run once. + // A type assertion keeps the literal's own record layout, which can + // equal another arm's layout (`{ kind: "return", value, loc } as Stmt` + // when a `throw` arm has the same fields), so assertions are looked + // through as well. if (expr.type.kind === "record" && expected.kind === "union") { let literal = node; - while (ts.isParenthesizedExpression(literal) || ts.isSatisfiesExpression(literal)) + while ( + ts.isParenthesizedExpression(literal) || + ts.isSatisfiesExpression(literal) || + ts.isAsExpression(literal) || + ts.isTypeAssertion(literal) + ) literal = literal.expression; const def = this.unions.get(expected.unionId); if (def?.discriminant && ts.isObjectLiteralExpression(literal)) { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6021-asserted-literal-union-arm.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6021-asserted-literal-union-arm.ts.json new file mode 100644 index 000000000..e3b27d23c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6021-asserted-literal-union-arm.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6021-asserted-literal-union-arm.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6021-asserted-literal-union-arm.ts b/tests/corpus/6021-asserted-literal-union-arm.ts new file mode 100644 index 000000000..cbe8b74f7 --- /dev/null +++ b/tests/corpus/6021-asserted-literal-union-arm.ts @@ -0,0 +1,42 @@ +// Object literals asserted to a discriminated union whose own fields match +// another member's fields: the discriminant selects the member. +interface Loc { + start: number; +} +type Expr = { kind: "num"; value: number; loc: Loc } | { kind: "str"; text: string; loc: Loc }; +type Stmt = + | { kind: "return"; value: Expr | null; awaited?: true; loc: Loc } + | { kind: "throw"; value: Expr; loc: Loc } + | { kind: "if"; cond: Expr; then: Stmt[]; loc: Loc }; + +function show(stmt: Stmt): string { + switch (stmt.kind) { + case "return": + return `return${stmt.awaited ? " awaited" : ""} ${stmt.value === null ? "-" : stmt.value.kind}`; + case "throw": + return `throw ${stmt.value.kind}`; + case "if": + return `if ${stmt.cond.kind} { ${stmt.then.map(show).join("; ")} }`; + } +} + +function guarded(value: Expr, loc: Loc): Stmt { + return { kind: "if", cond: value, then: [{ kind: "return", value, loc } as Stmt], loc }; +} + +function branches(reflect: boolean, value: Expr, loc: Loc): Stmt[] { + return [ + ...(reflect + ? [{ kind: "return", value, loc } as Stmt] + : [{ kind: "throw", value, loc } as Stmt, { kind: "return", value: null, loc } as Stmt]), + ]; +} + +const loc: Loc = { start: 0 }; +const num: Expr = { kind: "num", value: 1, loc }; +const str: Expr = { kind: "str", text: "x", loc }; +console.log(show(guarded(num, loc))); +console.log(branches(true, str, loc).map(show).join(" | ")); +console.log(branches(false, num, loc).map(show).join(" | ")); +const listed: Stmt[] = [{ kind: "return", value: str, loc } as Stmt, { kind: "throw", value: num, loc }]; +console.log(listed.map(show).join(" | "), listed.map((stmt) => stmt.kind).join(",")); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 954f0857d..9bb030c07 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1927,6 +1927,7 @@ "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, "6020-class-extends-function-constructor.js": 4300, + "6021-asserted-literal-union-arm.ts": 3400, "603-closures-rc-stress.ts": 1832, "604-closures-for-of.ts": 2639, "605-closures-forward-capture-tdz.ts": 3277, @@ -4635,6 +4636,7 @@ "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, "6020-class-extends-function-constructor.js": 7500, + "6021-asserted-literal-union-arm.ts": 7200, "603-closures-rc-stress.ts": 2589, "604-closures-for-of.ts": 3514, "605-closures-forward-capture-tdz.ts": 4638,