Fill an async result from a returned Promise<never>

`return fatal(...)` where `fatal` is declared `Promise<never>` (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<never>` as a
promise with a void payload, exactly like `Promise<void>`, 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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:38:15 -07:00
co-authored by Claude Opus 5.5
parent 3c5b9eadd2
commit e41fee0c4a
4 changed files with 67 additions and 0 deletions
@@ -13894,6 +13894,19 @@ export class Lowerer {
);
}
/** Whether `node`'s TS type is a `Promise<never>` (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<never>` as a
* promise with a void payload, exactly like `Promise<void>`, 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 <e>` (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>` 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);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/promise-never-return.ts"
],
"diags": []
}
+38
View File
@@ -0,0 +1,38 @@
// `return fatal(...)` where `fatal` is declared `Promise<never>`: 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<never> {
throw new Error(message);
}
async function run(flag: boolean): Promise<Outcome> {
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<number> {
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));
@@ -0,0 +1 @@
{ "plain": 2900, "san": 4100 }