From b767c38e9e1210fc70ad2ca7eab85e23ea7a9b61 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Thu, 29 Oct 2026 07:30:40 -0700 Subject: [PATCH] Build union spread merges as the checked-dynamic object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ ...base, ...override }` over union-typed sources has one field set per combination of source arms, so the merged value can carry fields from several arms at once. When the per-arm distribution (slice 1a) cannot pick one arm, the merge was refused with SC2003 and the site did not compile (provider-composer's all-optional compat unions, mcp's aggregate config). Build the object Node builds instead: the checked-dynamic literal, whose keys are written in source order through the existing dynamic-object path (field chunks, spreads by copy-data-properties, computed keys by propertyKey), then converted into the declared union by the existing checked conversion. Reflection, JSON and inspection see the keys Node keeps; a value the union cannot hold throws the catchable TypeError at the conversion rather than being reinterpreted as one arm. The fallback stays narrow: union-typed spread source, auto tier, no capture buffer, and a union the checked extraction can validate — everything that compiles today lowers exactly as before, and `--tier=static-only` keeps the refusal that tests/diagnostics/union-spread-merge.ts pins. Corpus union-spread-merge.ts pins key order, last-writer-wins, nested identity, the accepting typed consumer and the snapshot semantics differentially; dyncheck's "union spread merge" covers the refusing single-arm reinterpretation scriptc-only, since Node never checks a cast. Co-Authored-By: Claude Opus 5.5 --- .../lowering/expressions/object-literals.ts | 53 ++++++++++- .../tests/corpus/union-spread-merge.ts.json | 6 ++ tests/corpus/union-spread-merge.ts | 91 +++++++++++++++++++ tests/diagnostics/union-spread-merge.ts | 6 ++ .../__snapshots__/union-spread-merge.ts.txt | 8 +- .../corpus-costs/union-spread-merge.ts.json | 1 + tests/harness/dyncheck.test.ts | 34 +++++++ 7 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json create mode 100644 tests/corpus/union-spread-merge.ts create mode 100644 tests/harness/corpus-costs/union-spread-merge.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 b2e457681..18668ea98 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1322,9 +1322,8 @@ export function lowerObjectLiteral( // Distribute against the contextual union when there is one (its arms // carry the discriminants), else against the literal's own union. const ctx = lowerer.mapTypeOf(tsType); - const distributed = exact - ? null - : lowerDistributedUnionSpread(lowerer, expr, ctx?.kind === "union" ? ctx : mapped); + const unionCtx = ctx?.kind === "union" ? ctx : mapped; + const distributed = exact ? null : lowerDistributedUnionSpread(lowerer, expr, unionCtx); if (distributed) return distributed; // A spread merge over UNION-typed sources (`{ ...base, ...override }` // with `base`, `override` of arm-union types) has one field set per @@ -1332,8 +1331,13 @@ export function lowerObjectLiteral( // No single arm of the contextual union can hold that value without // dropping the fields the other arms contributed (Node keeps them // all), and a runtime tag would be a guess about which arm the value - // "is". Refuse, naming the candidate arms. + // "is". Build the object Node builds instead — the checked-dynamic + // copy — and let the union's checked conversion validate it. if (mapped?.kind === "union") { + if (!exact && unionCtx?.kind === "union") { + const merged = lowerUnionSpreadMergeDyn(lowerer, expr, unionCtx); + if (merged) return merged; + } const members = tsType.isUnionType() ? ts.constituentTypes(tsType) : [tsType]; const arms = [...new Set(members.map((m) => lowerer.checker.typeToString(m)))]; lowerer.pushDiag( @@ -2886,6 +2890,47 @@ function lowerDistributedUnionSpread( }; } +/** `{ ...a, ...b }` over union-typed sources that lowerDistributedUnionSpread + * declined: no single destination arm holds every key the contributors may + * write (`provider-composer`'s two all-optional compat unions, an aggregate + * config merged from a http|stdio union plus a patch naming both). Node + * builds ONE plain object: each source's own enumerable keys copy in source + * order (later writers win), then the explicit fields write in their own + * positions. Build exactly that object — the checked-dynamic literal — and + * validate it into the declared union at the slot. The value keeps its + * runtime keys (reflection, JSON, and inspection see what Node sees); a + * value the union cannot hold throws the catchable TypeError at the + * conversion instead of being reinterpreted as one arm. + * + * Null when the shape is not the SC2003 one (no union-typed spread source), + * when the build must not demote (inside a capture buffer, or under + * `--tier=static-only`, which keeps today's refusal), or when the union has + * no checked extraction. The caller then keeps its refusal. */ +function lowerUnionSpreadMergeDyn( + lowerer: Lowerer, + expr: ts.ObjectLiteralExpression, + target: IrType & { kind: "union" }, +): IrExpr | null { + if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return null; + const spreadsUnion = expr.properties.nodes.some((prop) => { + if (!ts.isSpreadAssignment(prop)) return false; + let node: ts.Expression = prop.expression; + while (ts.isParenthesizedExpression(node)) node = node.expression; + return lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "union"; + }); + if (!spreadsUnion) return null; + if (lowerer.tierMode !== "auto" || lowerer.diagSink !== null) return null; + if ( + !canDynCheckTo( + target, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) + return null; + return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr), target); +} + /** A union of plain records can preserve its existing variant while * replacing shared fields. Dispatch before constructing the copy: only * the selected arm evaluates overrides, and recordClone snapshots fields diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json new file mode 100644 index 000000000..aec1baf33 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-spread-merge.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-spread-merge.ts" + ], + "diags": [] +} diff --git a/tests/corpus/union-spread-merge.ts b/tests/corpus/union-spread-merge.ts new file mode 100644 index 000000000..c95ef04fc --- /dev/null +++ b/tests/corpus/union-spread-merge.ts @@ -0,0 +1,91 @@ +// A spread merge over union-typed sources whose destination union has no +// single arm for the result: `{ ...base, ...override }` can carry fields from +// several arms at once, and Node keeps them all. Where the per-arm +// distribution cannot pick one arm (see union-spread-distribution.ts), the +// merge builds the object Node builds — a checked-dynamic copy that writes +// keys in source order and validates the value at its typed slots. +// JSON pins key order, last-writer-wins and the kept keys; the identity check +// shows nested values are copied by reference; the typed consumer and the +// post-merge write exercise the checked conversion. + +interface OpenAiCompat { + supportsStore?: boolean; +} + +interface AnthropicCompat { + supportsLongCacheRetention?: boolean; +} + +type Compat = OpenAiCompat | AnthropicCompat; + +// 1. The provider-composer merge: every arm is all-optional, so no arm can +// represent the union of both key sets. The keys land in source order. +function mergeCompat(base: Compat | undefined, override: Compat | undefined): Compat | undefined { + if (!override) return base; + return { ...base, ...override } as NonNullable; +} + +const merged = mergeCompat({ supportsStore: true }, { supportsLongCacheRetention: true }); +if (merged !== undefined) console.log("merged", JSON.stringify(merged)); +const kept = mergeCompat({ supportsStore: true }, undefined); +if (kept !== undefined) console.log("no-override", JSON.stringify(kept)); +const filled = mergeCompat(undefined, { supportsLongCacheRetention: true }); +if (filled !== undefined) console.log("no-base", JSON.stringify(filled)); + +// 2. Overlapping keys: the later source wins the value, the earlier source +// keeps the key's position. +interface Left { + first?: string; + shared?: string; +} +interface Right { + shared?: string; + second?: string; +} +type Side = Left | Right; +function overlap(base: Side, override: Side): Side { + return { ...base, ...override } as Side; +} +console.log( + "overlap", + JSON.stringify(overlap({ first: "f", shared: "from-base" }, { shared: "from-override", second: "s" })), +); + +// 3. Nested values are copied by reference, not deep-copied. +interface Inner { + v: number; +} +interface XArm { + kind: "x"; + inner: Inner; + x?: number; +} +interface YArm { + kind: "y"; + inner: Inner; + y?: number; +} +type XY = XArm | YArm; +const inner: Inner = { v: 9 }; +const nested = { ...({ kind: "x", inner: { v: 1 }, x: 2 } as XY), ...({ kind: "y", inner, y: 3 } as XY) } as XY; +if (nested.kind === "y") console.log("nested", JSON.stringify(nested), nested.inner.v, nested.inner === inner); + +// 4. A typed consumer accepts the merged value; the checked conversion at the +// parameter is what validates it. +function describe(compat: Compat): string { + return JSON.stringify(compat); +} +if (merged !== undefined) console.log("describe", describe(merged)); +console.log("describe-single", describe({ supportsStore: false })); + +// 5. The merge snapshots its sources: a later write to a source is not seen. +const source: OpenAiCompat = { supportsStore: true }; +const snapshot = mergeCompat(source, { supportsLongCacheRetention: true }); +source.supportsStore = false; +if (snapshot !== undefined) console.log("snapshot", JSON.stringify(snapshot), JSON.stringify(source)); + +// 6. A merge with an explicit field after the spreads. +function withExtra(base: Compat, override: Compat): Compat { + return { ...base, ...override, supportsStore: true } as NonNullable; +} +console.log("extra", JSON.stringify(withExtra({ supportsStore: false }, { supportsLongCacheRetention: true }))); diff --git a/tests/diagnostics/union-spread-merge.ts b/tests/diagnostics/union-spread-merge.ts index dae422070..15d98c7b0 100644 --- a/tests/diagnostics/union-spread-merge.ts +++ b/tests/diagnostics/union-spread-merge.ts @@ -4,6 +4,12 @@ // can represent it (naming one would drop the other arms' fields, which Node // keeps), and no runtime tag would be honest. The refusal names the candidate // arms; the repair is one arm per merge or one open all-optional record type. +// +// This fixture pins the refusal under `--tier=static-only`. The default tier +// builds the object Node builds instead — a checked-dynamic copy whose keys +// are written in source order and validated at each typed slot (see +// tests/corpus/union-spread-merge.ts and the dyncheck fixture +// "union spread merge") — so it is static-only that keeps this diagnostic. interface OpenAiCompat { kind: "openai"; supportsStore?: boolean; diff --git a/tests/harness/__snapshots__/union-spread-merge.ts.txt b/tests/harness/__snapshots__/union-spread-merge.ts.txt index e3b39e2b6..a64fdecf5 100644 --- a/tests/harness/__snapshots__/union-spread-merge.ts.txt +++ b/tests/harness/__snapshots__/union-spread-merge.ts.txt @@ -1,8 +1,8 @@ -union-spread-merge.ts:21:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat') +union-spread-merge.ts:27:18 - error SC2003: a spread merge over union-typed sources builds a value that can carry fields from several arms at once, so no single arm of 'NonNullable' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat') - 20 | if (!override) return base; - 21 | const merged = { ...base, ...override } as NonNullable; + 26 | if (!override) return base; + 27 | const merged = { ...base, ...override } as NonNullable; | ^~~~~~~~~~~~~~~~~~~~~~~~ - 22 | return merged; + 28 | return merged; hint: merge ONE arm per path (narrow every spread source to that same arm first — a discriminant switch or an assertion — so the spread builds a single record), or declare ONE open record type whose fields are all optional instead of a union of all-optional arms \ No newline at end of file diff --git a/tests/harness/corpus-costs/union-spread-merge.ts.json b/tests/harness/corpus-costs/union-spread-merge.ts.json new file mode 100644 index 000000000..2bbfce362 --- /dev/null +++ b/tests/harness/corpus-costs/union-spread-merge.ts.json @@ -0,0 +1 @@ +{ "plain": 5100, "san": 3300 } diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index 97def0cc6..39739a128 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -1123,6 +1123,40 @@ console.log("recovered"); expect(r.stdout).toBe("TypeError true\nscalar: TypeError\nrecovered\n"); }); + test("a union spread merge's checked-dynamic object refuses a single-arm reinterpretation catchably", async () => { + // Where the per-arm distribution cannot pick one arm, a spread merge + // over union sources builds the object Node builds — the checked- + // dynamic copy — and the union's checked conversion validates it at + // every typed slot. Node keeps the merged keys through a narrower + // `as`; scriptc refuses to reinterpret them silently and throws the + // catchable TypeError instead. That makes this path scriptc-only: + // corpus union-spread-merge.ts pins the accepting paths differentially. + const r = await compileAndRun( + "union-spread-merge-dyn", + `interface OpenAiCompat { supportsStore?: boolean } +interface AnthropicCompat { supportsLongCacheRetention?: boolean } +type Compat = OpenAiCompat | AnthropicCompat; +function mergeCompat(base: Compat, override: Compat): Compat { + return { ...base, ...override } as Compat; +} +const merged = mergeCompat({ supportsStore: true }, { supportsLongCacheRetention: true }); +console.log(JSON.stringify(merged)); +try { + const single = merged as OpenAiCompat; + console.log("unreachable", JSON.stringify(single)); +} catch (e) { + console.log((e as Error).name, (e as Error).message.includes("not representable in the target union")); +} +console.log("recovered"); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe( + '{"supportsStore":true,"supportsLongCacheRetention":true}\nTypeError true\nrecovered\n', + ); + expect(r.stderr).toBe(""); + }); + test("a unit smuggled into a PLAIN non-nullable slot throws the stranded trap catchably", async () => { // The stranded-UNIT trap without a union in sight: `null!` / `null as // any as T` into a plain typed slot (string, array, class, function).