From b0bbae15ac2afcfeb56b328f531bd83c86ef880a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:40:53 -0700 Subject: [PATCH] Spread an empty-object default like its source `...(x ?? {})` and `...(x || {})` spread exactly what `...x` spreads, but the defaulted source lowered as a `??` expression into a checked-dynamic value, which failed for records with function fields. When x is a record or an optional record, the spread now lowers as a spread of x. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 47 ++++++++++++++++--- .../tests/corpus/spread-empty-default.ts.json | 6 +++ tests/corpus/spread-empty-default.ts | 45 ++++++++++++++++++ .../corpus-costs/spread-empty-default.ts.json | 1 + 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json create mode 100644 tests/corpus/spread-empty-default.ts create mode 100644 tests/harness/corpus-costs/spread-empty-default.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index ca40a9cf0..580c3ff95 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -549,8 +549,7 @@ function fenceAccessorSpreadSource( /** Only definitely present later records replace earlier defaults. A * callback binding may store undefined despite its checker annotation. */ function laterSpreadType(lowerer: Lowerer, expr: ts.Expression): IrType | null { - let source = expr; - while (ts.isParenthesizedExpression(source)) source = source.expression; + const source = spreadSourceNode(lowerer, expr); if (ts.isIdentifier(source)) return lowerer.lowerExpr(source).type; return lowerer.mapTypeOf(lowerer.typeOf(source)); } @@ -1892,8 +1891,7 @@ export function lowerObjectLiteral( // earlier ones (JS last-write-wins). Evaluate the source exactly // once, including empty sources and those whose fields are all // overridden. Field snapshots above preserve the copy order. - let srcNode: ts.Expression = prop.expression; - while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; + const srcNode = spreadSourceNode(lowerer, prop.expression); const condRecord = conditionalRecordSpreadOf(lowerer, prop.expression); let srcLowered = capture( condRecord !== null @@ -3022,14 +3020,51 @@ function lowerConditionalRecordSpread( }; } +/** A spread's source expression with parentheses stripped, seeing through + * an empty-object default: `...(x ?? {})` and `...(x || {})` spread exactly + * what `...x` spreads (a nullish or other falsy value is a primitive with + * no own keys, and x evaluates once either way). Only where `x` is a plain + * record or a record-or-unit union, which the field copy and the + * optional-spread desugar handle; index-signature and other sources keep + * their paths. */ +function spreadSourceNode(lowerer: Lowerer, expr: ts.Expression): ts.Expression { + let e: ts.Expression = expr; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if ( + !ts.isBinaryExpression(e) || + (e.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken && + e.operatorToken.kind !== ts.SyntaxKind.BarBarToken) + ) + return e; + let right: ts.Expression = e.right; + while (ts.isParenthesizedExpression(right)) right = right.expression; + if (!ts.isObjectLiteralExpression(right) || right.properties.nodes.length !== 0) return e; + let left: ts.Expression = e.left; + while (ts.isParenthesizedExpression(left)) left = left.expression; + const plainRecord = (t: IrType | undefined): boolean => + t?.kind === "record" && lowerer.shapes.get(t.shapeId)?.indexValue === undefined; + const source = lowerer.mapTypeOf(lowerer.typeOf(left)); + if (plainRecord(source ?? undefined)) return left; + if (source?.kind === "union") { + const arms = lowerer.unions.get(source.unionId)?.arms ?? []; + const records = arms.filter((arm) => arm.kind === "record"); + if ( + records.length === 1 && + plainRecord(records[0]) && + arms.every((arm) => arm.kind === "record" || isUnitType(arm)) + ) + return left; + } + return e; +} + /** A spread source the record desugar cannot enumerate: an untyped * (`unknown`) value, or one whose shape carries an index signature * (`Record`, `{ [key: string]: T }`) so its keys are * runtime state. Node copies own enumerable keys in insertion order, which * the checked-dynamic object reproduces. */ function dynSpreadSource(lowerer: Lowerer, node: ts.Expression): boolean { - let e: ts.Expression = node; - while (ts.isParenthesizedExpression(e)) e = e.expression; + const e = spreadSourceNode(lowerer, node); if ((lowerer.typeOf(e).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true; const indexed = (t: IrType | null): boolean => { if (t?.kind === "record") return lowerer.shapes.get(t.shapeId)?.indexValue !== undefined; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json new file mode 100644 index 000000000..9d75cf30c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/spread-empty-default.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/spread-empty-default.ts" + ], + "diags": [] +} diff --git a/tests/corpus/spread-empty-default.ts b/tests/corpus/spread-empty-default.ts new file mode 100644 index 000000000..4905b9619 --- /dev/null +++ b/tests/corpus/spread-empty-default.ts @@ -0,0 +1,45 @@ +// `...(options ?? {})` and `...(options || {})` spread exactly what +// `...options` spreads: nullish and other falsy values contribute no keys. +// The optional source is an Omit<> view of an options interface whose +// fields include a function, so a checked-dynamic copy cannot hold it. +interface LoaderOptions { + cwd: string; + agentDir: string; + extraPaths?: string[]; + onLoad?: (paths: string[]) => string[]; +} +interface ServiceOptions { + cwd: string; + loaderOptions?: Omit; +} +class Loader { + readonly options: LoaderOptions; + constructor(options: LoaderOptions) { + this.options = options; + } + describe(): string { + const paths = this.options.onLoad?.(this.options.extraPaths ?? []) ?? []; + return `${this.options.cwd} ${this.options.agentDir} [${paths.join(",")}]`; + } +} +function viaNullish(options: ServiceOptions): Loader { + return new Loader({ ...(options.loaderOptions ?? {}), cwd: options.cwd, agentDir: "/agent" }); +} +function viaOr(options: ServiceOptions): Loader { + return new Loader({ cwd: options.cwd, agentDir: "/or", ...(options.loaderOptions || {}) }); +} +let evaluated = 0; +function counted(options: ServiceOptions): ServiceOptions["loaderOptions"] { + evaluated++; + return options.loaderOptions; +} +function viaCall(options: ServiceOptions): Loader { + return new Loader({ ...(counted(options) ?? {}), cwd: options.cwd, agentDir: "/call" }); +} +const bare: ServiceOptions = { cwd: "/w" }; +const full: ServiceOptions = { cwd: "/w", loaderOptions: { extraPaths: ["x"], onLoad: (p) => [...p, "y"] } }; +console.log(viaNullish(bare).describe()); +console.log(viaNullish(full).describe()); +console.log(viaOr(bare).describe()); +console.log(viaOr(full).describe()); +console.log(viaCall(bare).describe(), viaCall(full).describe(), evaluated); diff --git a/tests/harness/corpus-costs/spread-empty-default.ts.json b/tests/harness/corpus-costs/spread-empty-default.ts.json new file mode 100644 index 000000000..da02be042 --- /dev/null +++ b/tests/harness/corpus-costs/spread-empty-default.ts.json @@ -0,0 +1 @@ +{ "plain": 4589, "san": 7483 }