Build union spread merges as the checked-dynamic object

`{ ...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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 19:23:44 -07:00
co-authored by Claude Opus 5.5
parent 8d6f723ed5
commit b767c38e9e
7 changed files with 191 additions and 8 deletions
@@ -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
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-spread-merge.ts"
],
"diags": []
}
+91
View File
@@ -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<Compat>;
}
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<Compat>;
}
console.log("extra", JSON.stringify(withExtra({ supportsStore: false }, { supportsLongCacheRetention: true })));
+6
View File
@@ -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;
@@ -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<Compat>' 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<Compat>' can represent it (candidate arms: 'AnthropicCompat', 'OpenAiCompat')
20 | if (!override) return base;
21 | const merged = { ...base, ...override } as NonNullable<Compat>;
26 | if (!override) return base;
27 | const merged = { ...base, ...override } as NonNullable<Compat>;
| ^~~~~~~~~~~~~~~~~~~~~~~~
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
@@ -0,0 +1 @@
{ "plain": 5100, "san": 3300 }
+34
View File
@@ -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).