Merge a union's promise arms into one promise of their payload union

`void | Promise<A> | Promise<void> | Decision` (the agent-loop `finishTurn`
hook, codemode's remote client) had no representation: a union arm's index is
its runtime tag and a promise arm's tag IS the `instanceof Promise` test, so a
second promise arm had no tag. The union was refused (SC2009) and every use
site inherited the blocker.

JavaScript cannot tell the arms apart: `instanceof Promise` answers true for
both and `await` settles to either payload. The union therefore maps to one
promise arm `Promise<A | undefined>`: payloads join like union arms (`void`
becomes the undefined unit, an already-union payload flattens, identical
payloads dedupe, a checked-dynamic payload absorbs the arms the dynamic
representation holds faithfully). A payload with no union arm home (a RegExp
payload, a promise-of-a-promise) keeps the whole union fenced, and the
refusal now says why.

A `Promise<A>` value flowing into the merged arm converts with the existing
checked promise-payload widening (promisePayloadWidens / promiseWiden): the
promise keeps its identity and its settlement turn, and the settled payload
is checked against the payload slot.

Cost: the merge only runs for a union that already has two promise arms,
which is refused today. Every other union interns the same arms in the same
order, so a fully typed program without such a union compiles to identical
IR.

Corpus 8414-promise-union-arms pins identity through the union slot,
`instanceof Promise` narrowing, `await`, `.then`, the void arm, the rejection
path and a non-promise data arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 13:17:05 -07:00
co-authored by Claude Opus 5.5
parent ab5b603e1f
commit ea23feeb21
7 changed files with 263 additions and 37 deletions
+106 -9
View File
@@ -5419,25 +5419,46 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
) {
return DYN;
}
// SEVERAL PROMISE ARMS are ONE: `Promise<A> | Promise<B>` is
// `Promise<A | B>`. A promise arm's tag IS the `instanceof Promise`
// test, so JavaScript cannot tell the arms apart at run time (both
// answer true, both `await` to a value of their payload), and the
// merged payload union is exactly what `await` produces either way.
// The merge happens here, in the union the checker gave us, so every
// use site (a declaration slot, an argument, an `await`, `.then`)
// sees the same one-promise-arm layout; the conversion of a
// `Promise<A>` value into it is the checked promise-payload widening
// (promisePayloadWidens / promiseWiden). A payload that cannot ride
// that union (a promise of a promise, a regex or generator payload)
// keeps the whole union fenced.
const mergedArms = mergePromiseArms(arms, ctx);
if (mergedArms === null) return null;
// Promise arms were the whole union (`Promise<void> | Promise<undefined>`
// merges to the plain `Promise<void>`): a one-armed union has no
// representation and needs none — the merge left the arm alone.
if (mergedArms.length === 1) {
if (unions.recursivePending(widened)) return null;
return mergedArms[0]!;
}
if (
arms.some(
mergedArms.some(
(a) =>
a.kind === "void" ||
a.kind === "union" ||
a.kind === "dyn" ||
// A generator arm only comes from an Iterable<T> (an optional
// one keeps its unit arms beside the iterable union's).
(a.kind === "generator" && iterableArmsElem(arms) === null),
(a.kind === "generator" && iterableArmsElem(mergedArms) === null),
) ||
!maybePromiseUnionOk(parts, arms, ctx)
!maybePromiseUnionOk(parts, mergedArms, ctx)
) {
return null;
}
arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
mergedArms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
const discriminant =
unionDiscriminant(recordParts, arms, ctx) ??
unionDiscriminant(recordParts, mergedArms, ctx) ??
(substitutedUnions.length === 1
? remapUnionDiscriminant(substitutedUnions[0]!, arms)
? remapUnionDiscriminant(substitutedUnions[0]!, mergedArms)
: undefined);
if (unions.recursivePending(widened)) {
// The knot closed through this union. A frame that resolved
@@ -5446,9 +5467,12 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// same ts.Type answers differently per instantiation — so
// recursive generic-open unions stay fenced.
if (contextResolutions !== sensitivityAtEntry) return null;
return { kind: "union", unionId: unions.finalizeRecursive(widened, arms, discriminant) };
return {
kind: "union",
unionId: unions.finalizeRecursive(widened, mergedArms, discriminant),
};
}
return { kind: "union", unionId: unions.intern(arms, discriminant) };
return { kind: "union", unionId: unions.intern(mergedArms, discriminant) };
} finally {
unions.inProgress.delete(widened);
}
@@ -8871,6 +8895,79 @@ function maybePromiseUnionOk(
return maybePromiseThenableArm(parts, ctx) === null;
}
/** Merge the promise arms of one union into a single `Promise<A | B>`
* (mapTypeInner's union rule): JavaScript-wise the arms are
* indistinguishable — `instanceof Promise` answers true for both, and
* `await` settles to the payload union — so the merged arm is the same
* value in the same slot. Payloads join like union arms: a `void` payload
* becomes the undefined unit, a payload that is already a union flattens,
* and a checked-dynamic payload (`Promise<unknown>`) absorbs the arms the
* checked-dynamic representation holds faithfully (dynSubsumableUnionArm).
* Null keeps the whole union fenced: a promise-of-a-promise payload, a
* payload with no union home (regex, generator), or a dyn payload beside an
* arm whose semantics would degrade. */
function mergePromiseArms(arms: IrType[], ctx: TypeMapperCtx): IrType[] | null {
let promiseArms = 0;
for (const arm of arms) if (arm.kind === "promise") promiseArms++;
if (promiseArms < 2) return arms;
const payloads: IrType[] = [];
let dynamic = false;
for (const arm of arms) {
if (arm.kind !== "promise") continue;
const inner = arm.inner;
if (inner.kind === "void") {
payloads.push(UNDEFINED_T);
continue;
}
if (inner.kind === "dyn") {
dynamic = true;
continue;
}
if (inner.kind === "union") {
const substituted = ctx.unions.get(inner.unionId);
if (!substituted?.arms.length) return null;
payloads.push(...substituted.arms);
continue;
}
payloads.push(inner);
}
if (dynamic) {
if (!payloads.every((arm) => dynSubsumableUnionArm(arm, ctx))) return null;
payloads.length = 0;
payloads.push(DYN);
}
const byKey = new Map<string, IrType>();
for (const arm of payloads) {
// A promise payload has no arm home beside the others: `await` of the
// merged promise would adopt it as a thenable, which no union tag does.
if (arm.kind === "promise") return null;
byKey.set(typeKey(arm), arm);
}
const payload = [...byKey.values()];
if (payload.length === 0) return null;
// Every arm is the undefined unit: the promise never settles a value,
// exactly `Promise<void>`.
if (payload.every((arm) => arm.kind === "undefinedT")) payload.length = 0;
// One payload arm is that arm itself (a regex or a promise payload has a
// promise slot but no union arm); a JOINT payload is a union, so its arms
// need union homes.
const inner: IrType | null =
payload.length === 0
? VOID
: payload.length === 1
? payload[0]!
: payload.every((arm) => armHasUnionHome(arm))
? {
kind: "union",
unionId: ctx.unions.intern(
[...payload].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)),
),
}
: null;
const merged: IrType | null = inner === null ? null : { kind: "promise", inner };
return merged === null ? null : [...arms.filter((arm) => arm.kind !== "promise"), merged];
}
/** The first non-promise constituent of a promise-armed union whose type
* has a `then` member (a thenable data arm), or null. */
function maybePromiseThenableArm(parts: readonly ts.Type[], ctx: TypeMapperCtx): ts.Type | null {
@@ -9025,7 +9122,7 @@ export function describeComponentBlocker(widened: ts.Type, ctx: TypeMapperCtx):
}
const promiseArms = mappedArms.filter(({ mapped }) => mapped?.kind === "promise");
if (promiseArms.length > 1) {
return `the union shape is supported, but it has several promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)`;
return `the union shape is supported, but its promise arms ('${text(promiseArms[0]!.p)}' and '${text(promiseArms[1]!.p)}') do not merge into one promise (their payloads have no common union layout)`;
}
if (promiseArms.length === 1 && dataArms.length > 1) {
const callable = mappedArms.find(({ mapped }) => mapped?.kind === "func");
@@ -77,16 +77,62 @@ test("an equal promise arm stays a plain copy", async () => {
expect(promisePayloadConversions(retag)).toBe(0);
});
test("two destination promise arms keep the conversion fence", async () => {
test("two destination promise arms merge into one widened payload", async () => {
// `Promise<unknown> | Promise<string>` is `Promise<unknown>`: the
// checked-dynamic payload absorbs the faithful one, so the destination is
// the same one-promise-arm union the single-arm case has.
const result = await compileSource(`
function settle(flag: boolean): Promise<void> | undefined {
return flag ? Promise.resolve() : undefined;
}
const ambiguous: Promise<unknown> | Promise<string> | undefined = settle(true);
console.log(ambiguous === undefined);
const merged: Promise<unknown> | Promise<string> | undefined = settle(true);
console.log(merged === undefined);
export {};
`);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.diagnostics.map((d) => d.code)).toContain("SC2003");
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const retag = result.module.functions.filter((fn) => fn.name.startsWith("%union.retag."));
expect(retag).toHaveLength(1);
expect(promisePayloadConversions(retag)).toBe(1);
});
test("several promise arms settle to one promise of the payload union", async () => {
const result = await compileSource(`
interface Decision { kind: "stop"; note: string }
interface Settled { reason: string }
function pick(n: number): void | Promise<Settled> | Promise<void> | Decision {
if (n === 0) return;
if (n === 1) return Promise.resolve({ reason: "one" });
return { kind: "stop", note: "x" };
}
async function main(): Promise<void> {
const hit = pick(1);
if (hit instanceof Promise) console.log("then", await hit.then((v) => v === undefined));
const decided = await pick(2);
console.log("decision", decided === undefined ? "?" : "note" in decided ? decided.note : "?");
}
void main();
export {};
`);
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const unions = result.module.unions ?? [];
// The IR invariant the merge establishes: no union holds two promise arms
// (the promise arm's tag IS the `instanceof Promise` test).
for (const union of unions)
expect(union.arms.filter((arm) => arm.kind === "promise").length).toBeLessThan(2);
// The declared union is the promise beside the undefined unit.
const armed = unions.filter((union) => union.arms.some((arm) => arm.kind === "promise"));
expect(armed.length).toBeGreaterThan(0);
const promiseArm = armed[0]!.arms.find((arm) => arm.kind === "promise");
expect(promiseArm?.kind).toBe("promise");
// `void | Promise<Settled> | Promise<void>` merges to a payload UNION:
// the settled record beside the undefined unit.
if (promiseArm?.kind === "promise") {
expect(promiseArm.inner.kind).toBe("union");
if (promiseArm.inner.kind === "union") {
const payload = unions.find((union) => union.id === promiseArm.inner.unionId);
expect(payload?.arms.map((arm) => arm.kind).sort()).toEqual(["record", "undefinedT"]);
}
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8417-promise-union-arms.ts"
],
"diags": []
}
+74
View File
@@ -0,0 +1,74 @@
// Several promise arms in one union: `void | Promise<A> | Promise<void> |
// Decision` is one promise arm `Promise<A | undefined>` — `instanceof
// Promise` is true for either arm and `await` settles to the payload union.
interface Decision {
kind: "stop" | "continue";
note: string;
}
interface Settled {
reason: string;
}
type TurnResult = void | Promise<Settled> | Promise<void> | Decision;
function pick(n: number): TurnResult {
if (n === 0) return;
if (n === 1) return Promise.resolve({ reason: "one" });
if (n === 2) return Promise.resolve();
return { kind: "continue", note: "x" };
}
function fail(): void | Promise<Settled> {
return Promise.reject(new Error("boom"));
}
class Agent {
finishTurn?: (turn: number) => TurnResult;
async turn(n: number): Promise<string> {
const hook = this.finishTurn;
if (!hook) return "none";
const out = await hook(n);
if (out === undefined) return "void";
if ("note" in out) return `decision:${out.note}`;
return `settled:${out.reason}`;
}
}
async function main(): Promise<void> {
// A promise stored in the union is the same object: the payload
// conversion never re-boxes it.
const original = Promise.resolve({ reason: "one" });
const stored: TurnResult = original;
console.log("identity", stored === original);
const hit = pick(1);
console.log("promise", hit instanceof Promise, pick(3) instanceof Promise);
if (hit instanceof Promise) {
const settled = await hit;
console.log("narrowed", settled === undefined ? "void" : "object");
console.log("then", await hit.then((value) => (value === undefined ? "u" : "s")));
}
console.log("void", (await pick(0)) === undefined);
const settled = await pick(1);
console.log("awaited", typeof settled === "object" && "reason" in settled ? settled.reason : "?");
const decided = await pick(3);
console.log("decision", typeof decided === "object" && "note" in decided ? decided.note : "?");
const empty = pick(2);
if (empty instanceof Promise) await empty.then(() => console.log("then void"));
try {
await fail();
} catch (error) {
console.log("rejected", (error as Error).message);
}
const agent = new Agent();
console.log("no hook", await agent.turn(1));
agent.finishTurn = pick;
console.log("hook", await agent.turn(1), await agent.turn(3), await agent.turn(0));
}
void main();
+5 -3
View File
@@ -10,11 +10,13 @@ function thenable(): Thenish | Promise<number> {
}
console.log(typeof thenable);
// Two promise arms: the union tag cannot tell them apart by instanceof.
function twoPromises(): Promise<number> | Promise<string> | boolean {
// Promise arms merge into one promise of their payload union (`await` settles
// to either payload). They do not merge when a payload has no union arm home:
// a RegExp payload is the promise slot's own, not an arm.
function splitPromises(): Promise<RegExp> | Promise<string> | boolean {
return true;
}
console.log(typeof twoPromises);
console.log(typeof splitPromises);
// Promises have no inspect lowering, so neither does a union holding one.
function maybe(b: boolean): string | Promise<string> {
@@ -16,36 +16,36 @@ maybe-promise-fences.ts:11:20 - error SC2004: uses of 'thenable' inherit the blo
hint: the declaration of 'thenable' did not compile — fix the diagnostic reported there and these sites clear with it
maybe-promise-fences.ts:14:10 - error SC2009: values of type 'boolean | Promise<string> | Promise<number>' cannot be compiled: the union shape is supported, but it has several promise arms ('Promise<string>' and 'Promise<number>'), and a compiled union holds at most one (the union tag is the 'instanceof Promise' test)
maybe-promise-fences.ts:16:10 - error SC2009: values of type 'boolean | Promise<string> | Promise<RegExp>' cannot be compiled: the union shape is supported, but its promise arms ('Promise<string>' and 'Promise<RegExp>') do not merge into one promise (their payloads have no common union layout)
13 | // Two promise arms: the union tag cannot tell them apart by instanceof.
14 | function twoPromises(): Promise<number> | Promise<string> | boolean {
| ^~~~~~~~~~~
15 | return true;
15 | // a RegExp payload is the promise slot's own, not an arm.
16 | function splitPromises(): Promise<RegExp> | Promise<string> | boolean {
| ^~~~~~~~~~~~~
17 | return true;
hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown')
maybe-promise-fences.ts:17:20 - error SC2004: uses of 'twoPromises' inherit the blocker on its declaration
maybe-promise-fences.ts:19:20 - error SC2004: uses of 'splitPromises' inherit the blocker on its declaration
16 | }
17 | console.log(typeof twoPromises);
| ^~~~~~~~~~~
18 |
18 | }
19 | console.log(typeof splitPromises);
| ^~~~~~~~~~~~~
20 |
hint: the declaration of 'twoPromises' did not compile — fix the diagnostic reported there and these sites clear with it
hint: the declaration of 'splitPromises' did not compile — fix the diagnostic reported there and these sites clear with it
maybe-promise-fences.ts:23:13 - error SC1090: console.log of 'Promise<string> | string' values ('Promise<string>' values have no inspect lowering) are not supported yet
maybe-promise-fences.ts:25:13 - error SC1090: console.log of 'Promise<string> | string' values ('Promise<string>' values have no inspect lowering) are not supported yet
22 | }
23 | console.log(maybe(true));
24 | }
25 | console.log(maybe(true));
| ^~~~~~~~~~~
24 |
26 |
maybe-promise-fences.ts:27:50 - error SC2020: 'Promise.resolve over 'Promise<string> | string' at this result type' is part of the standard library types but has no scriptc lowering yet
maybe-promise-fences.ts:29:50 - error SC2020: 'Promise.resolve over 'Promise<string> | string' at this result type' is part of the standard library types but has no scriptc lowering yet
26 | // must be that arm's own promise type.
27 | const widened = Promise.resolve<string | number>(maybe(false));
28 | // must be that arm's own promise type.
29 | const widened = Promise.resolve<string | number>(maybe(false));
| ^~~~~~~~~~~~
28 | console.log(typeof widened);
30 | console.log(typeof widened);
hint: the promise arm passes through identically, so it must already be the result's promise type
@@ -0,0 +1 @@
{ "plain": 4400, "san": 5400 }