diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 92135013a..0a83a9572 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -4819,6 +4819,11 @@ export function lowerObjectLiteral(L: Lowerer, expr: ts.ObjectLiteralExpression) } } const fieldTypes = new Map(shape.fields.map((f) => [f.name, f.type])); + // The clone probe may lower the leading spread before declining (a + // static `as` cast can make the checker-visible shape match while its + // erased IR value keeps a wider shape). Reuse that exact value in the + // ordinary spread path so lowering still happens once. + let leadingSpreadLowered: IrExpr | null = null; // The overwhelmingly common immutable-update form over a record: // `{ ...model, changed, other: value }`. The historic lowering expanded @@ -4865,20 +4870,23 @@ export function lowerObjectLiteral(L: Lowerer, expr: ts.ObjectLiteralExpression) names.every((name) => fieldTypes.has(name)) ) { const source = L.lowerExpr(spread.expression); - const overrides: { name: string; value: IrExpr }[] = []; - for (let i = 0; i < props.length; i++) { - const p = props[i]!; - const name = names[i]!; - const fieldType = fieldTypes.get(name)!; - const valueNode = ts.isPropertyAssignment(p) ? p.initializer : p; - let value = ts.isPropertyAssignment(p) - ? L.lowerExpr(p.initializer) - : L.lowerShorthandValue(p); - value = L.coerceInto(valueNode, value, fieldType); - if (!typeEquals(value.type, fieldType)) L.badType(valueNode, L.typeOf(valueNode)); - overrides.push({ name, value }); + if (source.type.kind === "record" && source.type.shapeId === type.shapeId) { + const overrides: { name: string; value: IrExpr }[] = []; + for (let i = 0; i < props.length; i++) { + const p = props[i]!; + const name = names[i]!; + const fieldType = fieldTypes.get(name)!; + const valueNode = ts.isPropertyAssignment(p) ? p.initializer : p; + let value = ts.isPropertyAssignment(p) + ? L.lowerExpr(p.initializer) + : L.lowerShorthandValue(p); + value = L.coerceInto(valueNode, value, fieldType); + if (!typeEquals(value.type, fieldType)) L.badType(valueNode, L.typeOf(valueNode)); + overrides.push({ name, value }); + } + return { kind: "recordClone", source, overrides, type, loc }; } - return { kind: "recordClone", source, overrides, type, loc }; + leadingSpreadLowered = source; } } @@ -5155,7 +5163,12 @@ export function lowerObjectLiteral(L: Lowerer, expr: ts.ObjectLiteralExpression) } let srcNode: ts.Expression = prop.expression; while (ts.isParenthesizedExpression(srcNode)) srcNode = srcNode.expression; - const srcLowered = ts.isIdentifier(srcNode) ? null : L.lowerExpr(srcNode); + const srcLowered = + prop === expr.properties[0] && leadingSpreadLowered !== null + ? leadingSpreadLowered + : ts.isIdentifier(srcNode) + ? null + : L.lowerExpr(srcNode); const srcType = srcLowered ? srcLowered.type : L.mapTypeOf(L.typeOf(srcNode)); // `...options.installConfig` — a spread of `Partial | undefined` // (the optional-options merge idiom `{ ...DEFAULTS, ...overrides }`): diff --git a/tests/corpus/2026-width-spread.ts b/tests/corpus/2026-width-spread.ts index f1aba2780..d3e9618c9 100644 --- a/tests/corpus/2026-width-spread.ts +++ b/tests/corpus/2026-width-spread.ts @@ -24,6 +24,12 @@ console.log(c1.inner.keep); const a2: A = { ...extra1, b: "override" }; console.log(a2.a, a2.b); +// A shape-changing static cast is erased in IR. The same-shape clone +// optimization must inspect that lowered value and decline to this width +// copy instead of cloning it as the asserted narrower shape. +const a3: A = { ...(extra1 as A), b: "cast override" }; +console.log(a3.a, a3.b); + // The optional-source merge idiom keeps its present-test semantics next // to the new lifts. type Opts = { mode?: string };