From d968975f346840a3bdb84ca5c4914977de6f03ff Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 01:14:02 -0700 Subject: [PATCH] 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), 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 --- .../lowering/expressions/object-literals.ts | 34 +++++++- packages/compiler/src/frontend/type-mapper.ts | 77 ++++++++++++++++--- .../6475-dynamic-spread-literals.ts.json | 6 ++ tests/corpus/6475-dynamic-spread-literals.ts | 63 +++++++++++++++ 4 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6475-dynamic-spread-literals.ts.json create mode 100644 tests/corpus/6475-dynamic-spread-literals.ts diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index 5a9d0ea10..77c4c8fc5 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -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) & { diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index de4e13de0..f2440a521 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -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`) 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`, 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)); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6475-dynamic-spread-literals.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6475-dynamic-spread-literals.ts.json new file mode 100644 index 000000000..29207c929 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6475-dynamic-spread-literals.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6475-dynamic-spread-literals.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6475-dynamic-spread-literals.ts b/tests/corpus/6475-dynamic-spread-literals.ts new file mode 100644 index 000000000..eaf76598e --- /dev/null +++ b/tests/corpus/6475-dynamic-spread-literals.ts @@ -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 { + const out: Record = {}; + 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, 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", []);