Lower promise methods called through an optional chain

`p?.catch(h)`, `p?.then(h)` and a guarded chain tail (`f()?.then(h)`)
were refused: the promise-method lowering rejected every
`questionDotToken` instead of asking whether the optional chain was
currently being lowered, so the guarded body never reached it. It uses
`chainBlocked` now, like every other lowering that rides the chain.

The call that ENDS a chain checker-types as the chain's undefined-armed
union (`Promise<T> | undefined`) while this lowering builds the promise
arm, which `finishOptionalChain` wraps back into the union. The
handler's result type takes that arm, so the handler is typed against
the promise it actually handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 05:37:50 -07:00
co-authored by Claude Opus 5.5
parent 279d42e562
commit bf28595035
3 changed files with 95 additions and 5 deletions
@@ -279,7 +279,11 @@ import { isThisParameter, type ParamShape } from "./call-signatures.js";
import { dynamicOverloadChoice } from "./overload-choice.js";
import { lowerStringIteratorValue } from "./iterator-adapters.js";
import { checkedArmExtraction } from "./coercions/unions.js";
import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js";
import {
maybePromiseReturnStmt,
promiseArmedUnionOf,
promiseOrUnitUnionOf,
} from "./maybe-promise.js";
import {
requireObjLitGenericReceiver,
objLitGenericFnNodeOf,
@@ -5998,9 +6002,19 @@ export function lowerPromiseMethodCall(
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (lowerer.chainBlocked(access, call)) return null;
const member = access.name.text;
if (member !== "then" && member !== "catch" && member !== "finally") return null;
// A call that ENDS an optional chain (`refreshing?.catch(h)`,
// `client?.close().catch(h)`) checker-types as the chain's
// undefined-armed union while the body this lowering builds is the
// promise arm — `finishOptionalChain` wraps it back into the union.
// Take the arm so the handler typing sees the promise it handles.
const callResultType = (): IrType | undefined => {
const t = lowerer.mapTypeOf(lowerer.typeOf(call));
if (!t || t.kind === "promise") return t ?? undefined;
return promiseOrUnitUnionOf(lowerer, t)?.promise ?? t;
};
let recvT =
lowerer.mapTypeOf(lowerer.typeOf(access.expression)) ??
dynFallbackType(lowerer, access.expression, lowerer.typeOf(access.expression));
@@ -6117,7 +6131,7 @@ export function lowerPromiseMethodCall(
const rejectedNode = call.arguments.nodes[1]!;
const fulfilledAbsent = isAbsentHandler(fulfilledNode);
const rejectedAbsent = isAbsentHandler(rejectedNode);
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
const resultT = callResultType();
// An untyped call in JavaScript whose receiver arrived as a typed
// promise (a function instantiated with one): Promise.prototype.then
// over the promise as a checked-dynamic value, which shares its
@@ -6516,7 +6530,7 @@ export function lowerPromiseMethodCall(
`then handlers whose parameter is not the settled value's type (expected '${lowerer.fmt(inner)}', got '${lowerer.fmt(param)}')`,
);
}
let resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
let resultT = callResultType();
// JavaScript whose declared result type has no mapping (zod's
// `Promise.all(items).then((r) => ParseStatus.mergeArray(status, r))`,
// typed by a .d.ts union): the handler's value settles the promise as
@@ -6864,7 +6878,7 @@ export function lowerPromiseMethodCall(
"value — take `(e)` or `(e: unknown)` and narrow with instanceof)",
);
}
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
const resultT = callResultType();
if (resultT?.kind !== "promise") {
lowerer.noLowering(
"catch with this handler's result type",
@@ -23,6 +23,18 @@ export function maybePromiseUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUn
return armed;
}
/** The promise arm of a promise-or-unit union (`Promise<void> | void`,
* `Promise<T> | undefined`): exactly one promise arm and only nullish
* arms beside it, so no other arm carries a value the promise path would
* have to reconcile. A guard that proves the value present (`?.`, an
* explicit narrow) leaves this arm alone. */
export function promiseOrUnitUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null {
const armed = promiseArmedUnionOf(lowerer, t);
if (!armed) return null;
if (!armed.def.arms.every((a, i) => i === armed.promiseTag || isUnitType(a))) return null;
return armed;
}
/** Any union with exactly one promise arm: a maybe-promise union or a
* promise-or-unit union (`Promise<void> | void`). An async `return`
* resolves both the same way: adopt the promise arm, fulfill otherwise. */
@@ -0,0 +1,64 @@
// `?.` reaches a promise method through a receiver that may be nullish:
// `p?.catch(handler)` guards the method read (JS calls nothing when `p` is
// nullish), and the chain tail `f()?.then(..).catch(..)` guards the whole
// tail. The guarded call itself is an ordinary promise method call, so the
// handler typing sees the promise arm, and the chain result is
// `Promise<..> | undefined`.
interface Client {
name: string;
}
class Box {
opening: Promise<Client> | undefined;
pending: Promise<void> | undefined;
constructor(opening: Promise<Client> | undefined, pending: Promise<void> | undefined) {
this.opening = opening;
this.pending = pending;
}
async run(): Promise<void> {
this.opening?.catch(() => undefined);
console.log("1 opening guarded");
await this.pending?.catch(() => undefined);
console.log("2 pending guarded");
}
}
function open(name: string): Promise<Client> {
return Promise.resolve({ name });
}
function pick(flag: boolean): Promise<string> | undefined {
return flag ? Promise.resolve("value") : undefined;
}
function tail(flag: boolean): Promise<number | undefined> {
return pick(flag)!
.then((value) => {
console.log("tail then", value);
return value.length;
})
.catch(() => undefined);
}
function guardedTail(flag: boolean): void {
pick(flag)?.then((value) => {
console.log("guarded then", value);
return value.length;
});
console.log("3 tail attached", flag);
}
const present = new Box(open("digest"), Promise.resolve());
const absent = new Box(undefined, undefined);
present
.run()
.then(() => absent.run())
.then(() => tail(true))
.then(() => {
guardedTail(true);
guardedTail(false);
})
.then(() => console.log("done"));