Let implicit JS instances that fall through return undefined

A JavaScript function specialized per call site takes its declared
return type from JSDoc. bcryptjs's `hash` and `compare` document
`@returns {!Promise}` but return nothing when a callback is passed, so
their instances ended with the "the checker proved every path returns"
trap and every callback-style call aborted with SC9002. Non-generic
JavaScript functions already widen such a return with an undefined arm;
implicit instances now do the same.

The widened `Promise<unknown> | undefined` result is then awaited by
TypeScript callers, which was refused: an await of a promise-of-dynamic
or unit union now goes through the dynamic await, which adopts the
promise or hops once for the unit, as Node does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 17:48:01 -07:00
co-authored by Claude Opus 5.5
parent f8fa60afec
commit 20f15f89fd
7 changed files with 71 additions and 0 deletions
@@ -13,6 +13,7 @@ import {
} from "../../ir/ir.js";
import { isJsSourceFile, locOf } from "../program.js";
import { typeKey } from "../type-mapper.js";
import { functionCanReturnUndefined } from "../function-completion.js";
import { PoisonError, dynFallbackType, neverTaintedJsType, newFnCtx } from "./lowerer.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { findGenericMethodOn, type ClassInfo } from "./lower-classes.js";
@@ -1428,6 +1429,16 @@ function implicitDeclaredReturn(lowerer: Lowerer, info: GenericFnInfo): IrType |
return null;
if (mapped !== null && promiseCarriesDyn(lowerer, mapped) && !hasExplicitJsDocReturn(info.decl))
return null;
// A JavaScript body that can complete without returning yields
// undefined there, whatever its JSDoc claims (bcryptjs's `hash`
// returns a promise only when no callback is passed), exactly as
// declaredReturnType widens non-generic JavaScript functions.
if (
mapped !== null &&
isJsSourceFile(info.decl.getSourceFile()) &&
functionCanReturnUndefined(info.decl)
)
return lowerer.withUndefinedArmOf(mapped) ?? mapped;
return mapped;
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
@@ -1722,6 +1722,18 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
const promiseArm = def.arms[promiseTag]!;
const inner = promiseArm.kind === "promise" ? promiseArm.inner : VOID;
const units = def.arms.filter(isUnitType);
// A checked-dynamic inner: the whole operand is a dyn value (the
// promise boxes by reference, the unit as itself), and the dyn
// await adopts the promise or hops once for the unit.
if (inner.kind === "dyn" && lowerer.dynConvertible(value.type)) {
return {
kind: "libCall",
fn: "async.awaitDyn",
args: [lowerer.coerceToExpected(value, DYN)],
type: DYN,
loc,
};
}
if (inner.kind === "union" || inner.kind === "dyn") {
// A union inner would need an arm-wise re-tag into the result
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts"
],
"diags": []
}
@@ -0,0 +1,9 @@
// A package function whose JSDoc promises a Promise but that returns
// nothing when a callback is passed (bcryptjs's hash and compare). The
// call that falls through completes normally, as in Node.
import { hash } from "callback-hash";
hash("pw", 4, (err, value) => {
console.log("callback", err, value);
});
console.log("promise", await hash("pw", "salt"));
@@ -0,0 +1,6 @@
export declare function hash(input: string, salt: number | string): Promise<string>;
export declare function hash(
input: string,
salt: number | string,
callback: (err: Error | null, value?: string) => void,
): void;
@@ -0,0 +1,26 @@
/**
* @param {string} input
* @param {number|string} salt
* @param {function(Error, string=)=} callback
* @param {function(number)=} progress
* @returns {!Promise}
*/
export function hash(input, salt, callback, progress) {
function run(done) {
if (progress) progress(1);
done(null, `${input}:${salt}`);
}
if (callback) {
if (typeof callback !== "function") throw Error("Illegal callback: " + typeof callback);
run(callback);
} else
return new Promise(function (resolve, reject) {
run(function (err, res) {
if (err) {
reject(err);
return;
}
resolve(res);
});
});
}
@@ -0,0 +1 @@
{ "name": "callback-hash", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" }