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<T>` into `Promise<unknown> |
undefined`, `(s) => Promise<string | null>` into an optional
`(s) => Promise<string | null | undefined>` 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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 10:07:51 -07:00
co-authored by Claude Opus 5.5
parent 8a22e0f515
commit 30e6ca9160
5 changed files with 125 additions and 0 deletions
@@ -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<K, Promise<unknown>>`):
// 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 {
@@ -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<T>` into `Promise<unknown> |
// undefined`, `() => Promise<string | null>` into an optional
// `() => Promise<string | null | undefined>` 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).
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/union-promise-function-arm-widening.ts"
],
"diags": []
}
@@ -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<string, Promise<unknown>>();
async function serialize<T>(key: string, task: () => Promise<T>): Promise<T> {
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<unknown>;
lookup?: (name: string) => Promise<string | null | undefined>;
scale?: (n: number) => number | string;
}
async function findName(name: string): Promise<string | null> {
return name.length > 3 ? name.toUpperCase() : null;
}
function double(n: number): number {
return n * 2;
}
async function failing(): Promise<number | undefined> {
throw new Error("task failed");
}
async function main(): Promise<void> {
const a = await serialize("alpha", async () => 1);
const b = await serialize("alpha", async () => "two");
const c = await serialize("beta", async (): Promise<boolean | undefined> => 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<number> = Promise.resolve(41);
store.pending = work;
console.log(store.pending === work, store.pending !== work);
const other: Promise<number> = 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();
@@ -0,0 +1 @@
{ "plain": 14002, "san": 16102 }