Build dict-spreading literals with other members as checked-dynamic objects

An object literal that spreads an index-signature record but has
members outside the scalar join (an array of labels), an unknown-valued
spread (Record<string, unknown>), or a member a later spread may
overwrite with another type now maps to the checked-dynamic value: it is
built like a JavaScript object literal, so spreads copy their own keys
in order and later keyed writes land on the same object.

'c && { k: v }' spreads contribute k exactly when c is truthy even where
the checker's union of the two does not map (an AbortSignal member), and
a conditional spread onto an 'unknown' field leaves the checked-dynamic
undefined in its empty arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-20 02:01:48 -07:00
co-authored by Claude Opus 5.5
parent 2553818b0c
commit d968975f34
4 changed files with 167 additions and 13 deletions
@@ -1700,7 +1700,11 @@ export function lowerObjectLiteral(
}
shapeMismatch(prop);
}
const absent = lowerer.absentFieldValue(fieldType, locOf(prop));
// An 'unknown' slot holds the checked-dynamic undefined for the
// empty arm, the absent-property read the width copy uses too.
const absent =
lowerer.absentFieldValue(fieldType, locOf(prop)) ??
(fieldType.kind === "dyn" ? dynUndefinedExpr(locOf(prop)) : null);
if (!absent) {
lowerer.unsupported(
"SC1090",
@@ -2628,6 +2632,14 @@ export function andSpreadOf(
while (ts.isParenthesizedExpression(right)) right = right.expression;
if (!ts.isObjectLiteralExpression(right)) return null;
const spreadType = lowerer.mapTypeOf(lowerer.typeOf(e));
// Every falsy value is a primitive, which spreads nothing, so `c && {
// ... }` contributes the literal's members exactly when c is truthy,
// whatever c's type. The checker's union of the two decides the
// representation where it maps; where it does not (a member typed by a
// checked-dynamic value such as an AbortSignal), the syntax decides.
if (spreadType === null || spreadType.kind === "dyn") {
return syntacticAndSpread(right, e.left);
}
if (spreadType?.kind !== "union") return null;
const arms = lowerer.unions.get(spreadType.unionId)?.arms ?? [];
if (
@@ -2659,6 +2671,26 @@ export function andSpreadOf(
return { cond: e.left, props, whenTrue: true };
}
function syntacticAndSpread(
right: ts.ObjectLiteralExpression,
cond: ts.Expression,
): { cond: ts.Expression; props: CondSpreadProp[]; whenTrue: boolean } | "unsupported" {
if (right.properties.nodes.length === 0) return "unsupported";
const props: CondSpreadProp[] = [];
for (const p of right.properties.nodes) {
if (
(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) &&
p.name &&
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))
) {
props.push(p as CondSpreadProp);
} else {
return "unsupported";
}
}
return { cond, props, whenTrue: true };
}
/** A conditional spread's carrier property: `name: value` or shorthand,
* with an identifier/string-literal name. */
export type CondSpreadProp = (ts.PropertyAssignment | ts.ShorthandPropertyAssignment) & {
+65 -12
View File
@@ -5861,6 +5861,7 @@ function mapRecordType(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
)
return DYN;
const merged = dictSpreadLiteralShape(widened, inner, ctx);
if (merged === "dyn") return DYN;
if (merged !== null) return { kind: "record", shapeId: merged };
const refined = refinedConstituentShape(widened, inner, ctx);
if (refined !== null) return { kind: "record", shapeId: refined };
@@ -5889,7 +5890,7 @@ function dictSpreadLiteralShape(
widened: ts.Type,
parts: RecordShapeParts,
ctx: TypeMapperCtx,
): string | null {
): string | "dyn" | null {
if (parts.indexValue) return null;
const symbol = widened.getSymbol();
if (!symbol || (symbol.flags & ts.SymbolFlags.ObjectLiteral) === 0) return null;
@@ -5908,16 +5909,29 @@ function dictSpreadLiteralShape(
// mapping.
const contextualRaw = checker.getContextualType(literal);
const contextual = contextualRaw ? checker.getNonNullableType(contextualRaw) : undefined;
// An unknown-valued slot (`Record<string, unknown>`) is the checked-dynamic
// value, which has no typed merge of its own: the literal still needs its
// own record.
// A literal written into a declared slot lowers into that slot (a typed
// index-signature record merges into it, a declared record converts),
// and its own type keeps its mapping. Only a literal with no declared
// destination (an unannotated binding, a type parameter, `any` or
// `unknown`, an unknown-valued record) is built by its own type.
// A generic argument's context is the instantiated parameter: the
// literal's own type when the type argument was inferred from it.
const ownContext =
contextual !== undefined &&
(contextual === widened || contextual.getSymbol()?.declarations.includes(literal) === true);
if (
contextual &&
checker
.getIndexInfosOfType(contextual)
.some((info) => (info.valueType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0)
)
return null;
!ownContext &&
!(contextual.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Any | ts.TypeFlags.Unknown))
) {
const infos = checker.getIndexInfosOfType(contextual);
const unknownValued =
infos.length > 0 &&
infos.every(
(info) => (info.valueType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0,
);
if (!unknownValued) return null;
}
const kinds = props.map((p) =>
ts.isSpreadAssignment(p) ? spreadSourceKindTs(checker, p.expression) : "member",
);
@@ -5934,6 +5948,7 @@ function dictSpreadLiteralShape(
// pure index-signature record.
if (t === widened) return null;
const mapped = mapType(t, ctx);
if (mapped?.kind === "dyn") return DYN;
const shape = mapped?.kind === "record" ? shapes.get(mapped.shapeId) : undefined;
return shape?.indexValue && shape.fields.length === 0 && !shape.tuple ? shape.indexValue : null;
};
@@ -5947,6 +5962,11 @@ function dictSpreadLiteralShape(
return true;
};
const before: string[] = [];
// An unknown-valued spread (`Record<string, unknown>`, or another such
// literal) makes the object the checked-dynamic value: it is built like
// a JavaScript object literal (copy each spread's own keys in order), and
// member reads check their declared types.
let unknownValued = false;
for (const [i, p] of props.entries()) {
// A fixed-shape or conditional spread contributes its own members,
// which the checker already types exactly (they are in `parts`).
@@ -5958,11 +5978,32 @@ function dictSpreadLiteralShape(
}
if (ts.isSpreadAssignment(p)) {
const slot = dictSlotOf(p.expression);
if (!slot || !addArms(slot)) return null;
// A member written before this spread may be overwritten by it.
if (slot?.kind === "dyn") {
unknownValued = true;
continue;
}
if (!slot) return null;
if (!addArms(slot)) {
// A slot outside the scalar join still has the checked-dynamic
// representation when it converts to it.
if (
!canConvertToDyn(
slot,
(id) => shapes.get(id),
(id) => unions.get(id),
)
)
return null;
unknownValued = true;
}
// A member written before this spread may be overwritten by it: the
// typed join keeps member types only where the slot is that type;
// otherwise the checked-dynamic object (whose member reads check
// their types) holds whatever the spread wrote.
for (const name of before) {
const field = parts.fields.find((f) => f.name === name);
if (!field || !typeEquals(field.type, slot)) return null;
if (!field) return null;
if (!typeEquals(field.type, slot)) unknownValued = true;
}
continue;
}
@@ -5972,6 +6013,18 @@ function dictSpreadLiteralShape(
before.push(name.text);
}
if (parts.fields.some((f) => f.name.startsWith("%"))) return null;
// Members outside the scalar join (an array of labels beside the tags)
// make the object the checked-dynamic value too.
if (!unknownValued && !parts.fields.every((f) => addArms(f.type))) unknownValued = true;
if (unknownValued) {
const getRecord = (id: string) => shapes.get(id);
const getUnion = (id: string) => unions.get(id);
return parts.fields.every(
(f) => f.type.kind === "dyn" || canConvertToDyn(f.type, getRecord, getUnion),
)
? "dyn"
: null;
}
for (const field of parts.fields) if (!addArms(field.type)) return null;
if (![...arms.values()].some((arm) => !isUnitType(arm))) return null;
const sorted = [...arms.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6475-dynamic-spread-literals.ts"
],
"diags": []
}
@@ -0,0 +1,63 @@
// Object literals spreading index-signature records whose members are not
// all scalars, or whose spread values are unknown, are checked-dynamic
// objects built like JavaScript's: each spread copies its own keys in
// order, conditional spreads add nothing when they do not apply, and later
// keyed writes land on the same object.
function flatten(obj: unknown, prefix: string): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (typeof obj === "object" && obj !== null) {
for (const [k, v] of Object.entries(obj)) out[`${prefix}.${k}`] = v;
}
return out;
}
function summary(status: string, ms: number, bytes: number | undefined, err: string | undefined) {
const milestones = { connect: 3, done: 9 };
const resultTags = {
"git.fetch.status": status,
"git.fetch.durationMs": ms,
...flatten(milestones, "git.fetch"),
...(bytes !== undefined && { "git.fetch.packBytes": bytes }),
...(err !== undefined && { "git.fetch.error": err }),
};
console.log(JSON.stringify({ event: "git.fetch.summary", version: "v1", ...resultTags }));
}
summary("ok", 12, 100, undefined);
summary("failed", 3, undefined, "boom");
type SendOptions = { method: string; headersTimeout?: number; signal?: AbortSignal };
function send(url: string, options: SendOptions): string {
return `${url} ${options.method} ${options.headersTimeout ?? "-"} ${options.signal ? options.signal.aborted : "none"}`;
}
function callWith(timeout: number | undefined, attemptTimeout: number | undefined): string {
return send("u", {
headersTimeout: timeout,
method: "GET",
...(attemptTimeout && { signal: AbortSignal.timeout(attemptTimeout) }),
});
}
console.log(callWith(5, 1000));
console.log(callWith(undefined, undefined));
console.log(callWith(5, 0));
function report(tags: string[], from: string, logs: [string, string][]) {
const ddTags = {
"errClass.from": from,
"errClass.tagLabels": Array.from(new Set(tags)).sort(),
"errClass.tags": "",
...tags.reduce((obj: Record<string, boolean>, tag: string) => {
obj[`errClass.tags.${tag}`] = true;
return obj;
}, {}),
"errClass.logLinesSent": logs.length,
"errClass.lastRule": "none",
};
if (tags.length > 1) {
ddTags["errClass.lastRule"] = "rule-2";
}
for (const tag of ["late"]) {
// @ts-expect-error -- keys outside the literal's declared ones
ddTags[`errClass.tags.${tag}`] = true;
}
ddTags["errClass.tags"] = tags.join(",");
console.log(JSON.stringify(ddTags), ddTags["errClass.logLinesSent"] + 1);
}
report(["b", "a"], "build", [["stdout", "x"]]);
report([], "init", []);