From bf285950351105acf9463e99635fbc5a4d3ef4b9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 21:12:28 -0700 Subject: [PATCH] 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 | 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 --- .../src/frontend/lowering/lower-calls.ts | 24 +++++-- .../src/frontend/lowering/maybe-promise.ts | 12 ++++ tests/corpus/promise-catch-optional-chain.ts | 64 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/corpus/promise-catch-optional-chain.ts diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 45d470c60..34587b078 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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", diff --git a/packages/compiler/src/frontend/lowering/maybe-promise.ts b/packages/compiler/src/frontend/lowering/maybe-promise.ts index 8323c2e91..8cc7d7e35 100644 --- a/packages/compiler/src/frontend/lowering/maybe-promise.ts +++ b/packages/compiler/src/frontend/lowering/maybe-promise.ts @@ -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`, + * `Promise | 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`). An async `return` * resolves both the same way: adopt the promise arm, fulfill otherwise. */ diff --git a/tests/corpus/promise-catch-optional-chain.ts b/tests/corpus/promise-catch-optional-chain.ts new file mode 100644 index 000000000..638fb8417 --- /dev/null +++ b/tests/corpus/promise-catch-optional-chain.ts @@ -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 | undefined; + pending: Promise | undefined; + + constructor(opening: Promise | undefined, pending: Promise | undefined) { + this.opening = opening; + this.pending = pending; + } + + async run(): Promise { + this.opening?.catch(() => undefined); + console.log("1 opening guarded"); + await this.pending?.catch(() => undefined); + console.log("2 pending guarded"); + } +} + +function open(name: string): Promise { + return Promise.resolve({ name }); +} + +function pick(flag: boolean): Promise | undefined { + return flag ? Promise.resolve("value") : undefined; +} + +function tail(flag: boolean): Promise { + 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"));