Skip to content

noFloatingPromises false positive: bare await of an async wrapper flagged only when the wrapper's return type is inferred, not annotated #11038

Description

@neogenix

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-Type-InferenceArea: type inferenceL-JavaScriptLanguage: JavaScript and super languagesS-Bug-confirmedStatus: report has been confirmed as a valid bug

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions