From e41fee0c4a2c591e695d4c0e74917e352f5e47ed Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 13:18:27 -0700 Subject: [PATCH] Fill an async result from a returned `Promise` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `return fatal(...)` where `fatal` is declared `Promise` (a throw-only async helper) is well-typed — `never` converts into anything — but the returned value flattens through the returned-promise await into a void, which then fails the async function's declared record result with SC2002 (`expected '{ cancelled: boolean }', got 'void'`). The IR keeps `Promise` as a promise with a void payload, exactly like `Promise`, so the two are indistinguishable by type alone. The return now asks the TS payload type: a `never` payload means the promise never settles, so the awaited value is unreachable and fills the declared result through divergentValue — the same unreachable-value path a never-returning call already takes in a value position. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 22 +++++++++++ .../tests/corpus/promise-never-return.ts.json | 6 +++ tests/corpus/promise-never-return.ts | 38 +++++++++++++++++++ .../corpus-costs/promise-never-return.ts.json | 1 + 4 files changed, 67 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json create mode 100644 tests/corpus/promise-never-return.ts create mode 100644 tests/harness/corpus-costs/promise-never-return.ts.json diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index b928055eb..af2086a0a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -13894,6 +13894,19 @@ export class Lowerer { ); } + /** Whether `node`'s TS type is a `Promise` (the throw-only async + * function's declared result): the promise can never settle, so the value + * it would resolve to is unreachable. The IR keeps `Promise` as a + * promise with a void payload, exactly like `Promise`, so the TS + * payload type is what separates the two. */ + private promisePayloadIsNever(node: ts.Expression): boolean { + const t = this.typeOf(node); + if ((t.flags & ts.TypeFlags.Object) === 0) return false; + if (((t as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0) return false; + const args = this.checker.getTypeArguments(t as ts.TypeReference); + return args.length === 1 && (args[0]!.flags & ts.TypeFlags.Never) !== 0; + } + /** The value of `return ` (already lowered untyped) in an async or * void context: a promise flattens through the returned-promise await, * everything else converts into the declared result. */ @@ -13901,6 +13914,15 @@ export class Lowerer { if (expected.kind === "void" && e.kind === "unitLit") return null; if (this.ctx.isAsync && e.type.kind === "promise" && expected.kind !== "promise") { const awaited = this.returnedPromiseAwait(e); + // A `Promise` never settles, so the awaited value is + // unreachable: it fills any result shape instead of failing the + // void-to-record conversion. + if ( + expected.kind !== "void" && + e.type.inner.kind === "void" && + this.promisePayloadIsNever(node) + ) + return this.divergentValue(awaited, expected); return this.coerceInto(node, awaited, expected); } return this.coerceInto(node, e, expected); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json new file mode 100644 index 000000000..b69caef07 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/promise-never-return.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/promise-never-return.ts" + ], + "diags": [] +} diff --git a/tests/corpus/promise-never-return.ts b/tests/corpus/promise-never-return.ts new file mode 100644 index 000000000..b494ee211 --- /dev/null +++ b/tests/corpus/promise-never-return.ts @@ -0,0 +1,38 @@ +// `return fatal(...)` where `fatal` is declared `Promise`: the promise +// can never settle, so the awaited value is unreachable and fills the async +// function's record result instead of failing a void-to-record conversion. + +interface Outcome { + cancelled: boolean; +} + +async function fatal(message: string): Promise { + throw new Error(message); +} + +async function run(flag: boolean): Promise { + try { + if (flag) throw new Error(`zap ${flag}`); + return { cancelled: false }; + } catch (error: unknown) { + return fatal(error instanceof Error ? error.message : "unknown"); + } +} + +async function passthrough(): Promise { + try { + throw new Error("always"); + } catch { + return fatal("passthrough"); + } +} + +run(false) + .then((outcome) => console.log("resolved:", outcome.cancelled)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); +run(true) + .then((outcome) => console.log("resolved:", outcome.cancelled)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); +passthrough() + .then((value) => console.log("value:", value)) + .catch((error) => console.log("rejected:", error instanceof Error ? error.message : error)); diff --git a/tests/harness/corpus-costs/promise-never-return.ts.json b/tests/harness/corpus-costs/promise-never-return.ts.json new file mode 100644 index 000000000..6cf92fddb --- /dev/null +++ b/tests/harness/corpus-costs/promise-never-return.ts.json @@ -0,0 +1 @@ +{ "plain": 2900, "san": 4100 }