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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:37:45 -07:00
co-authored by Claude Opus 5.5
parent 980a1af682
commit b0bbae15ac
4 changed files with 93 additions and 6 deletions
@@ -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<string, unknown>`, `{ [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;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/spread-empty-default.ts"
],
"diags": []
}
+45
View File
@@ -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<LoaderOptions, "cwd" | "agentDir">;
}
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);
@@ -0,0 +1 @@
{ "plain": 4589, "san": 7483 }