From 30e6ca9160d33cb24933fc392e86c36358dfc681 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 10:07:51 -0700 Subject: [PATCH] Convert promise and function values into a union's arm of their kind A promise or function value flowing into a union whose only arm of that kind has a wider payload or result (`Promise` into `Promise | undefined`, `(s) => Promise` into an optional `(s) => Promise` member) was refused with SC2003: an arm value wrapped into a union only when the union held an identical arm. It now takes the arm-level conversion (the checked payload widening, the function adapter) and wraps the result. `p === u` against such a union compares both sides as untyped values, so a promise keeps its identity although converting it into the union's arm builds a new promise. Co-Authored-By: Claude Opus 5.5 --- .../frontend/lowering/expressions/binary.ts | 25 +++++++ .../compiler/src/frontend/lowering/lowerer.ts | 25 +++++++ ...nion-promise-function-arm-widening.ts.json | 6 ++ .../union-promise-function-arm-widening.ts | 68 +++++++++++++++++++ ...nion-promise-function-arm-widening.ts.json | 1 + 5 files changed, 125 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-promise-function-arm-widening.ts.json create mode 100644 tests/corpus/union-promise-function-arm-widening.ts create mode 100644 tests/harness/corpus-costs/union-promise-function-arm-widening.ts.json diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index c172fc662..01b48554e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -2388,6 +2388,31 @@ export function lowerStrictEquality( loc, }; } + // A plain promise against a union whose promise arm has a different + // payload (`chains.get(id) === tail` with a `Map>`): + // wrapping would convert the promise into that arm, and the converted + // promise is a new object. Promises are objects to ===, so compare both + // sides as untyped values, where a converted promise answers with its + // origin's identity (the plain promise-vs-promise rule below). + const plainPromise = + left.type.kind === "promise" ? left : right.type.kind === "promise" ? right : null; + if ( + !bothUnion && + plainPromise !== null && + lowerer.armTag(ut.unionId, plainPromise.type) < 0 && + unionArms.some((arm) => arm.kind === "promise") && + lowerer.dynConvertible(left.type) && + lowerer.dynConvertible(right.type) + ) { + return { + kind: "dynScalarEq", + left: lowerer.coerceToExpected(left, DYN), + right: lowerer.coerceToExpected(right, DYN), + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { aliasRefusal(ut); return { diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ee7477e43..059fab488 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -10397,6 +10397,31 @@ export class Lowerer { } } } + // A promise or function value against a union whose ONLY arm of + // that kind differs from it (`Promise` into `Promise | + // undefined`, `() => Promise` into an optional + // `() => Promise` member): convert into + // that arm with the arm-level conversion (the checked payload + // widening, the function adapter), then wrap. Several arms of the + // kind stay ambiguous and fall through to the union fence. + if (expr.type.kind === "promise" || expr.type.kind === "func") { + const arms = this.unions.get(expected.unionId)?.arms ?? []; + const candidates = arms.filter((arm) => arm.kind === expr.type.kind); + const arm = candidates.length === 1 ? candidates[0]! : null; + if (arm) { + const converted = this.coerceToExpected(expr, arm); + if (converted !== expr && typeEquals(converted.type, arm)) { + return { + kind: "unionWrap", + unionId: expected.unionId, + tag: arms.indexOf(arm), + value: converted, + type: expected, + loc: expr.loc, + }; + } + } + } // A width-coercible value against a union: coerce into the SINGLE // width-liftable arm, then wrap like any arm value (widthLiftPlan's // liftWrap — several candidate arms are ambiguous and decline). diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-promise-function-arm-widening.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-promise-function-arm-widening.ts.json new file mode 100644 index 000000000..91c268992 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/union-promise-function-arm-widening.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/union-promise-function-arm-widening.ts" + ], + "diags": [] +} diff --git a/tests/corpus/union-promise-function-arm-widening.ts b/tests/corpus/union-promise-function-arm-widening.ts new file mode 100644 index 000000000..751af6f41 --- /dev/null +++ b/tests/corpus/union-promise-function-arm-widening.ts @@ -0,0 +1,68 @@ +// A promise or function value flowing into a union whose only arm of that +// kind has a wider payload or result: the value converts into that arm. +// Promises keep their identity under === and !== against the union. + +const chains = new Map>(); + +async function serialize(key: string, task: () => Promise): Promise { + const previous = chains.get(key) ?? Promise.resolve(); + const queued = (async () => { + await previous.catch(() => {}); + return task(); + })(); + const tail = queued.catch(() => {}); + chains.set(key, tail); + void tail.then(() => { + if (chains.get(key) === tail) chains.delete(key); + console.log(`settled ${key}: ${chains.get(key) !== tail}`); + }); + return queued; +} + +interface Store { + pending?: Promise; + lookup?: (name: string) => Promise; + scale?: (n: number) => number | string; +} + +async function findName(name: string): Promise { + return name.length > 3 ? name.toUpperCase() : null; +} + +function double(n: number): number { + return n * 2; +} + +async function failing(): Promise { + throw new Error("task failed"); +} + +async function main(): Promise { + const a = await serialize("alpha", async () => 1); + const b = await serialize("alpha", async () => "two"); + const c = await serialize("beta", async (): Promise => true); + console.log(a, b, c, chains.size); + try { + await serialize("alpha", failing); + } catch (e) { + console.log("caught", (e as Error).message); + } + for (let i = 0; i < 4; i++) await Promise.resolve(); + console.log("remaining", chains.size); + + const store: Store = { lookup: findName, scale: double }; + const work: Promise = Promise.resolve(41); + store.pending = work; + console.log(store.pending === work, store.pending !== work); + const other: Promise = Promise.resolve(41); + console.log(store.pending === other); + console.log(await store.pending); + + const lookup = store.lookup; + if (lookup) console.log(await lookup("abcd"), await lookup("ab")); + console.log(store.scale?.(21)); + const empty: Store = {}; + console.log(empty.pending === work, empty.lookup === undefined, empty.scale?.(1)); +} + +main(); diff --git a/tests/harness/corpus-costs/union-promise-function-arm-widening.ts.json b/tests/harness/corpus-costs/union-promise-function-arm-widening.ts.json new file mode 100644 index 000000000..5c3da345d --- /dev/null +++ b/tests/harness/corpus-costs/union-promise-function-arm-widening.ts.json @@ -0,0 +1 @@ +{ "plain": 14002, "san": 16102 }