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 }