Environment information
Version: 2.5.5 (via `bunx @biomejs/biome@2.5.5 --version`)
Runtime: Bun 1.3.11
biome.json:
{
"$schema": "https://biomejs.dev/schemas/2.5.5/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"nursery": {
"noFloatingPromises": "error"
}
}
}
}
Rule name
noFloatingPromises
Input code
declare function maybeAsync(x: string): Response | Promise<Response>;
// (A) No explicit return-type annotation -- return type is INFERRED.
async function wrapperInferred(x: string) {
return maybeAsync(x);
}
// (B) Explicit `: Promise<Response>` annotation -- otherwise identical.
async function wrapperAnnotated(x: string): Promise<Response> {
return maybeAsync(x);
}
async function demo() {
await wrapperInferred('a'); // FALSE POSITIVE: flagged, "unsafe fix" suggests `await await`
await wrapperAnnotated('b'); // correctly NOT flagged
const r = await wrapperInferred('c'); // also correctly NOT flagged (assigned, not bare)
return r;
}
void demo();
Expected result
No diagnostics. wrapperInferred is declared async, so per the ECMAScript/TypeScript spec its actual return type is always a single, flattened Promise<Response> -- async functions can never return a nested/nested-union promise, regardless of what the body's own return expression's static type looks like. await wrapperInferred('a') is already correctly and fully awaited.
Actual result
input.ts:14:3 lint/nursery/noFloatingPromises FIXABLE ─────────────────────
× A "floating" Promise was found, meaning it is not properly handled and
│ could lead to ignored errors or unexpected behavior.
13 │ async function demo() {
> 14 │ await wrapperInferred('a'); // FALSE POSITIVE: flagged, "unsafe fix" suggests `await await`
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^
15 │ await wrapperAnnotated('b'); // correctly NOT flagged
16 │ const r = await wrapperInferred('c'); // also correctly NOT flagged (assigned, not bare)
i This happens when a Promise is not awaited, lacks a `.catch` or `.then`
│ rejection handler, or is not explicitly ignored using the `void`
│ operator.
i Unsafe fix: Add await operator.
14 │ ··await·await·wrapperInferred('a');
│ ++++++
The suggested "unsafe fix" (await await wrapperInferred('a')) is itself evidence this is wrong -- double-awaiting a function that's declared async is nonsensical.
Notes / narrowed cause
Isolated this down to exactly one variable: whether the wrapping async function's return type is inferred from its body vs. explicitly annotated.
wrapperInferred's body is return maybeAsync(x);, where maybeAsync is typed to return the union Response | Promise<Response> (this mirrors a real, common pattern -- e.g. Hono's Hono.request(): Response | Promise<Response>, which is where I first hit this). With no explicit return-type annotation on wrapperInferred, biome's type inference appears to infer wrapperInferred's return type as (something equivalent to) Promise<Response | Promise<Response>> -- i.e. it wraps the callee's union return type in one Promise<> layer for the async keyword, but does not additionally collapse/flatten it the way Awaited<T> (and the real TS checker) would for an async function's declared return type. A single await then only unwraps one layer in the rule's model, leaving what it believes is still a possibly-unhandled Promise<Response> inside -- hence "floating".
wrapperAnnotated has the exact same body, but an explicit : Promise<Response> annotation. With the annotation present, biome appears to trust the annotation directly rather than re-deriving it from the body, so it never goes through the buggy inference path, and correctly recognizes a single await as sufficient.
- Assigning the result to a variable (
const r = await wrapperInferred('c')) also side-steps the bug, since the "floating" check is specifically about a Promise-typed value being produced and then discarded as a bare expression statement -- an assigned value is "handled" from that check's perspective regardless of the type-layering bug.
Real-world impact: this fires on ordinary, correctly-awaited calls to any un-annotated async function wrapper around a union-returning callee (any such helper, anywhere), with no way to silence it except a per-line // biome-ignore (there's nothing actually wrong to fix). It's also somewhat scope-sensitive -- I initially failed to reproduce it when checking a single file in isolation outside its original project, until I confirmed nursery rule config plus body-return-type inference (rather than checking a file with no config at all) were both actually in effect; that scoping wrinkle might be worth its own look if it turns out noFloatingPromises's type resolution behaves differently for a single-file biome check <path> vs a whole-project biome check ..
Possibly related (adjacent area, not the same repro): #9989.
Playground link
Not included -- the repro depends on a biome.json with nursery.noFloatingPromises explicitly enabled, which the single-file playground doesn't carry; the inline repro above is copy-paste-runnable with bunx @biomejs/biome@2.5.5 check and the biome.json shown.
Environment information
biome.json:{ "$schema": "https://biomejs.dev/schemas/2.5.5/schema.json", "linter": { "enabled": true, "rules": { "recommended": true, "nursery": { "noFloatingPromises": "error" } } } }Rule name
noFloatingPromisesInput code
Expected result
No diagnostics.
wrapperInferredis declaredasync, so per the ECMAScript/TypeScript spec its actual return type is always a single, flattenedPromise<Response>--asyncfunctions can never return a nested/nested-union promise, regardless of what the body's ownreturnexpression's static type looks like.await wrapperInferred('a')is already correctly and fully awaited.Actual result
The suggested "unsafe fix" (
await await wrapperInferred('a')) is itself evidence this is wrong -- double-awaiting a function that's declaredasyncis nonsensical.Notes / narrowed cause
Isolated this down to exactly one variable: whether the wrapping
async function's return type is inferred from its body vs. explicitly annotated.wrapperInferred's body isreturn maybeAsync(x);, wheremaybeAsyncis typed to return the unionResponse | Promise<Response>(this mirrors a real, common pattern -- e.g. Hono'sHono.request(): Response | Promise<Response>, which is where I first hit this). With no explicit return-type annotation onwrapperInferred, biome's type inference appears to inferwrapperInferred's return type as (something equivalent to)Promise<Response | Promise<Response>>-- i.e. it wraps the callee's union return type in onePromise<>layer for theasynckeyword, but does not additionally collapse/flatten it the wayAwaited<T>(and the real TS checker) would for anasync function's declared return type. A singleawaitthen only unwraps one layer in the rule's model, leaving what it believes is still a possibly-unhandledPromise<Response>inside -- hence "floating".wrapperAnnotatedhas the exact same body, but an explicit: Promise<Response>annotation. With the annotation present, biome appears to trust the annotation directly rather than re-deriving it from the body, so it never goes through the buggy inference path, and correctly recognizes a singleawaitas sufficient.const r = await wrapperInferred('c')) also side-steps the bug, since the "floating" check is specifically about a Promise-typed value being produced and then discarded as a bare expression statement -- an assigned value is "handled" from that check's perspective regardless of the type-layering bug.Real-world impact: this fires on ordinary, correctly-awaited calls to any un-annotated
async functionwrapper around a union-returning callee (any such helper, anywhere), with no way to silence it except a per-line// biome-ignore(there's nothing actually wrong to fix). It's also somewhat scope-sensitive -- I initially failed to reproduce it when checking a single file in isolation outside its original project, until I confirmed nursery rule config plus body-return-type inference (rather than checking a file with no config at all) were both actually in effect; that scoping wrinkle might be worth its own look if it turns outnoFloatingPromises's type resolution behaves differently for a single-filebiome check <path>vs a whole-projectbiome check ..Possibly related (adjacent area, not the same repro): #9989.
Playground link
Not included -- the repro depends on a
biome.jsonwithnursery.noFloatingPromisesexplicitly enabled, which the single-file playground doesn't carry; the inline repro above is copy-paste-runnable withbunx @biomejs/biome@2.5.5 checkand thebiome.jsonshown.