From 5a6f2c0902a564368f1aa032c9a677d0b63d4ebe Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 19:17:05 -0700 Subject: [PATCH] Destructure `options ?? {}` over records with required members `const { a, ...rest } = options ?? {}` destructures the options record or an empty object. Where the record had a required member, the `{}` fallback was coerced into the record and failed with SC2002. The source now lowers to the union of the record and the empty record, so the union pattern lowering reads each key from the arm that holds it, the empty arm answers undefined and the rest element packs nothing. A record arm without a literal key now answers undefined in unionKeyGet too, which the frontend already asked for and the validator rejected as an internal error. All-optional options keep their lowering. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/expr-dynamic.ts | 13 ++ .../src/frontend/lowering/lower-stmts.ts | 2 + .../lowering/nullish-empty-pattern.ts | 136 ++++++++++++++++++ packages/compiler/src/ir/validate.test.ts | 51 +++++++ packages/compiler/src/ir/validate.ts | 8 ++ .../record-nullish-empty-destructure.ts.json | 6 + .../record-nullish-empty-destructure.ts | 83 +++++++++++ .../record-nullish-empty-destructure.ts.json | 1 + 8 files changed, 300 insertions(+) create mode 100644 packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json create mode 100644 tests/corpus/record-nullish-empty-destructure.ts create mode 100644 tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 7f4b7e48a..0fb9ea4ff 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -547,6 +547,19 @@ export function emitDynamicExpr( B.br(join); return; } + // A declared-only arm without the literal key: the object has no + // such own property, so the read answers undefined. + if (literal !== null && !shape.indexValue) { + const tag = resultDef?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1; + if (tag < 0 || e.type.kind !== "union") { + throw new InternalCompilerError( + "llvm emitter bug: unionKeyGet missing key without an undefined result arm", + ); + } + B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, tag)}, ptr ${slot}`); + B.br(join); + return; + } // Index-signature arm (or declared-only shape under a runtime // key): the shared keyed-read chain — a literal key naming no // declared field touches only the overflow map. diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 28d7e7c22..0e20791fd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js"; import { lowerEventObjectPropertyWrite } from "./lower-event-target.js"; import { lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { importMetaBindingSource } from "./import-meta.js"; +import { nullishEmptyPatternSource } from "./nullish-empty-pattern.js"; import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js"; import { notIterableSpelling } from "./iterable-spelling.js"; import { isPrivateSplitConsumer } from "./split-consumers.js"; @@ -2485,6 +2486,7 @@ export function lowerDestructuringDecl( } let init = importMetaBindingSource(lowerer, decl.name, decl.initializer) ?? + nullishEmptyPatternSource(lowerer, decl.name, decl.initializer) ?? lowerer.lowerExpr(decl.initializer); const parseArgsDynObject = init.type.kind === "dyn" && diff --git a/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts new file mode 100644 index 000000000..66c1f00e7 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nullish-empty-pattern.ts @@ -0,0 +1,136 @@ +import { BOOL, type IrExpr, type IrType, isUnitType, typeKey } from "../../ir/ir.js"; +import { isJsSourceFile, locOf } from "../program.js"; +import * as ts from "../ts7/adapter.js"; +import type { Lowerer } from "./lowerer.js"; + +function stripParens(node: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(node)) node = node.expression; + return node; +} + +/** `const { a, ...rest } = options ?? {}` (also `|| {}`) where the options + * record has a member that cannot be absent. The pattern destructures + * either the record or an empty object, which has no own keys, so the + * source lowers to a union of the record and the empty record: the union + * pattern lowering reads each key from the arm that holds it and answers + * undefined from the empty arm, and a rest element packs the record's + * remaining fields or nothing. The options operand is evaluated once. + * + * Null (the caller keeps its current lowering) for JavaScript files, other + * initializer forms, an operand the checker does not type as one record + * plus nullish arms, and a record whose every field may be absent: the + * existing lowering already builds the empty object as that record with + * every key absent. */ +export function nullishEmptyPatternSource( + lowerer: Lowerer, + pattern: ts.BindingName, + initializer: ts.Expression, +): IrExpr | null { + if (!ts.isObjectBindingPattern(pattern)) return null; + if (isJsSourceFile(ts.getSourceFileOfNode(initializer))) return null; + const init = stripParens(initializer); + if ( + !ts.isBinaryExpression(init) || + (init.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken && + init.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + ) + return null; + const right = stripParens(init.right); + if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return null; + const loc = locOf(init); + // Decide from the checker's view of the operand before lowering it once. + const declared = lowerer.mapTypeOf(lowerer.typeOf(init.left)); + if (declared?.kind !== "union") return null; + const declaredArms = lowerer.unions.get(declared.unionId)?.arms ?? []; + const declaredRecords = declaredArms.filter((arm) => arm.kind === "record"); + if ( + declaredRecords.length !== 1 || + !declaredArms.every((arm) => arm.kind === "record" || isUnitType(arm)) + ) + return null; + const declaredShape = lowerer.shapes.get( + (declaredRecords[0] as IrType & { kind: "record" }).shapeId, + ); + if ( + !declaredShape || + declaredShape.tuple || + declaredShape.indexValue || + declaredShape.fields.length === 0 || + declaredShape.fields.every( + (field) => lowerer.absentFieldValue(field.type, loc)?.kind === "fieldAbsent", + ) + ) + return null; + const left = lowerer.lowerExpr(init.left); + // The stored operand can differ from the checker's view. A stored record + // is never nullish (the fallback is dead); any other stored form must be + // the same one record plus nullish arms. + if (left.type.kind === "record") return left; + if (left.type.kind !== "union") { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const sourceId = left.type.unionId; + const arms = lowerer.unions.get(sourceId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if (records.length !== 1 || !arms.every((arm) => arm.kind === "record" || isUnitType(arm))) { + lowerer.unsupported( + "SC1031", + init, + "destructuring `x ?? {}` where the stored x is not one record or nullish (narrow first)", + ); + } + const record = records[0] as IrType & { kind: "record" }; + const empty: IrType = { kind: "record", shapeId: lowerer.shapes.intern([]) }; + const resultArms = [record, empty].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const resultType: IrType = { kind: "union", unionId: lowerer.unions.intern(resultArms) }; + const sourceTag = arms.indexOf(record); + const tmp = lowerer.declareHiddenLocal("%nullishEmpty", left.type); + const ref: IrExpr = { kind: "varRef", localId: tmp.id, type: left.type, loc }; + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: tmp.id, init: left, loc }], + result: { + kind: "ternary", + cond: { + kind: "unionIsTag", + unionId: sourceId, + tag: sourceTag, + negated: false, + value: ref, + type: BOOL, + loc, + }, + then: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(record), + value: { + kind: "unionNarrow", + unionId: sourceId, + tag: sourceTag, + value: ref, + type: record, + loc, + }, + type: resultType, + loc, + }, + else_: { + kind: "unionWrap", + unionId: resultType.unionId, + tag: resultArms.indexOf(empty), + value: { kind: "recordLit", fields: [], type: empty, loc }, + type: resultType, + loc, + }, + type: resultType, + loc, + }, + type: resultType, + loc, + }; +} diff --git a/packages/compiler/src/ir/validate.test.ts b/packages/compiler/src/ir/validate.test.ts index 83e0f9a31..727d350af 100644 --- a/packages/compiler/src/ir/validate.test.ts +++ b/packages/compiler/src/ir/validate.test.ts @@ -2183,3 +2183,54 @@ test("a caught-typed global records a module error only from a catch binding", ( 'in main: assign to catch binding "%error" (frontend must reject)', ]); }); + +test("a record arm without the literal key answers undefined in unionKeyGet", () => { + const uX: IrType = { kind: "union", unionId: "uX" }; + const uS: IrType = { kind: "union", unionId: "uS" }; + const rA: IrType = { kind: "record", shapeId: "rA" }; + const module = (answer: IrType): IrModule => ({ + irVersion: 17, + sourceFile: loc.file, + entry: "main", + records: [ + { id: "rA", fields: [{ name: "x", type: uX }] }, + { id: "rE", fields: [] }, + ], + unions: [ + { id: "uX", arms: [STRING, UNDEFINED_T] }, + { id: "uS", arms: [rA, { kind: "record", shapeId: "rE" }] }, + ], + functions: [ + { + name: "main", + params: [{ localId: "p", name: "p", type: uS }], + locals: [{ id: "p", name: "p", type: uS, mutable: false }], + returnType: answer, + body: [ + { + kind: "return", + value: { + kind: "unionKeyGet", + unionId: "uS", + key: { kind: "strLit", value: "x", type: STRING, loc }, + value: { kind: "varRef", localId: "p", type: uS, loc }, + type: answer, + loc, + }, + loc, + }, + ], + loc, + }, + ], + }); + // The empty arm lacks "x": it has no such own property, so it answers + // the result's undefined arm. + expect(validateModule(module(uX))).toEqual([]); + // Without an undefined arm in the result there is no answer to give. + expect( + validateModule(module(STRING)).some((d) => + d.message.includes("unionKeyGet: arm 1 of uS has no answer for the key"), + ), + ).toBe(true); +}); diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 995603bcd..4c563d729 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -3869,6 +3869,14 @@ function validateFunction( } return; } + // A declared-only arm that lacks a literal key has no such own + // property: it answers undefined, like a unit arm. + if ( + literal !== null && + !shape.indexValue && + resultUnion?.arms.some((a) => a.kind === "undefinedT") + ) + return; if (!shape.indexValue && !(literal === null && shape.fields.length > 0)) { err(`unionKeyGet: arm ${i} of ${e.unionId} has no answer for the key`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..e7da7aa73 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-nullish-empty-destructure.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-nullish-empty-destructure.ts" + ], + "diags": [] +} diff --git a/tests/corpus/record-nullish-empty-destructure.ts b/tests/corpus/record-nullish-empty-destructure.ts new file mode 100644 index 000000000..aff75af76 --- /dev/null +++ b/tests/corpus/record-nullish-empty-destructure.ts @@ -0,0 +1,83 @@ +// `const { a, ...rest } = options ?? {}` (and `|| {}`): the pattern +// destructures either the options record or an empty object. Each key reads +// undefined from the empty object, a default applies to it, and the rest +// element packs the record's remaining own keys or nothing. The options +// expression is evaluated once. + +interface EditorOptions { + description: string; + paddingX: number; + label?: string; +} + +function editor(options?: EditorOptions): string { + const { description, ...editorOptions } = options ?? {}; + return `${description} ${JSON.stringify(editorOptions)} [${Object.keys(editorOptions).join(",")}]`; +} +console.log(editor()); +console.log(editor({ description: "d", paddingX: 2 })); +console.log(editor({ description: "d", paddingX: 2, label: "l" })); + +// Every member optional: the empty object is the same layout with every +// key absent. +interface StreamOptions { + apiKey?: string; + env?: string; + signal?: string; + transformHeaders?: (headers: string) => string; +} + +function stream(options?: StreamOptions): string { + const { transformHeaders, apiKey, ...rest } = options ?? {}; + const headers = transformHeaders ? transformHeaders("h") : "none"; + return `${headers} ${apiKey} ${JSON.stringify(rest)} ${"signal" in rest}`; +} +console.log(stream()); +console.log(stream({ apiKey: "k", signal: "s" })); +console.log(stream({ transformHeaders: (h) => h + "!", env: "e" })); + +// A nested optional record, renamed keys and a default. +interface Entry { + options?: { deferred: boolean; signal?: string; step: number }; +} + +function submit(entry: Entry): string { + const { deferred: _deferred, signal: _signal, step = -1, ...submission } = entry.options ?? {}; + return `${step} ${JSON.stringify(submission)}`; +} +console.log(submit({})); +console.log(submit({ options: { deferred: true, step: 4 } })); +console.log(submit({ options: { deferred: false, signal: "x", step: 0 } })); + +// `||` and a null arm. +interface Retry { + headers: string; + retries: number; +} + +function retry(value: Retry | null): string { + const { headers, retries = 3 } = value || {}; + return `${headers} ${retries} ${typeof retries}`; +} +console.log(retry(null)); +console.log(retry({ headers: "h", retries: 2 })); + +// A generic options parameter. +function generic(options?: TOptions): string { + const { apiKey, ...providerOptions } = options ?? {}; + return `${apiKey} ${JSON.stringify(providerOptions)}`; +} +console.log(generic()); +console.log(generic({ maxTokens: 3, apiKey: "k" })); + +// The options expression runs once. +let calls = 0; +function current(): EditorOptions | undefined { + calls++; + return calls === 1 ? undefined : { description: "second", paddingX: 1 }; +} +const { description: first } = current() ?? {}; +const { description: second, paddingX } = current() ?? {}; +console.log(first, second, paddingX, calls); + +export {}; diff --git a/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json new file mode 100644 index 000000000..c56636be7 --- /dev/null +++ b/tests/harness/corpus-costs/record-nullish-empty-destructure.ts.json @@ -0,0 +1 @@ +{ "plain": 6298, "san": 72106 }