From 20f15f89fdbab54dc12342a87d38893bb738d139 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:43:22 -0700 Subject: [PATCH] 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 | 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 --- .../frontend/lowering/generic-functions.ts | 11 ++++++++ .../src/frontend/lowering/lower-exprs.ts | 12 +++++++++ .../main.ts.json | 6 +++++ .../main.ts | 9 +++++++ .../node_modules/callback-hash/index.d.ts | 6 +++++ .../node_modules/callback-hash/index.js | 26 +++++++++++++++++++ .../node_modules/callback-hash/package.json | 1 + 7 files changed, 71 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js create mode 100644 tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index bd785a256..11265be8b 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -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; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index c54341df8..6ec10e8d4 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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 diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json new file mode 100644 index 000000000..39de1b842 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts new file mode 100644 index 000000000..fb61368f7 --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/main.ts @@ -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")); diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts new file mode 100644 index 000000000..819294565 --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.d.ts @@ -0,0 +1,6 @@ +export declare function hash(input: string, salt: number | string): Promise; +export declare function hash( + input: string, + salt: number | string, + callback: (err: Error | null, value?: string) => void, +): void; diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js new file mode 100644 index 000000000..2661ea6ab --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/index.js @@ -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); + }); + }); +} diff --git a/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json new file mode 100644 index 000000000..d3553db20 --- /dev/null +++ b/tests/corpus/6190-npm-jsdoc-promise-fallthrough/node_modules/callback-hash/package.json @@ -0,0 +1 @@ +{ "name": "callback-hash", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" }