Skip to content

Params collapses to [never] when a $ placeholder carries a cast ($1::int) or a name ($id) #228

Description

@tiagolauer

Any $ placeholder whose text after the $ is not purely numeric collapses into slot 0 of the indexed tuple instead of getting its own slot. Two of them in the same query intersect into never, so the query becomes impossible to call.

Two shapes hit this, and both are ordinary SQL:

  • a Postgres cast attached to a placeholder — where created_at > $1::timestamptz
  • a SQLite named placeholder — where id = $id (the node:sqlite adapter binds $name, @name and :name; see SQLITE_PARAM_PREFIXES in src/adapters/node-sqlite.ts)

Reproduction

Schema { users: { id: number; name: string; created_at: Date; deleted_at: string | null }, orders: { ... } }.

query Params<DB, Q> expected
select id from users where id = $1 and name = $2 [number, string]
select id from users where id = $2::int and name = $1 [never] [string, number]
select id from users where id = $id and name = $nm [never] [number, string]
select id from users where id = @id and name = @nm [number, string] ✅ (the @ path is correct)

[never] means the call site can never be satisfied: there is no value assignable to never.

Root cause

src/params.ts:

type PlaceholderPosition<Token extends string> =
  CleanScanToken<Token> extends `$${infer Digits}`
    ? DigitsToCounter<Digits> extends [unknown, ...infer Position extends unknown[]]
      ? Position
      : never
    : never;

For $1::int, Digits is "1::int" and DigitsToCounter resolves to never. But never extends [unknown, ...infer Position] is true (never is assignable to everything), so PlaceholderPosition does not fall through to the named branch — it returns a bogus position and AddParam routes the token into the indexed bucket:

type AddParam<...> = PlaceholderPosition<Token> extends infer Position
  ? [Position] extends [never]      // never taken for $1::int / $id
    ? ...named/sequential path...
    : ...indexed path...

SetSlot then writes every such placeholder to the same slot, intersecting the types (number & string = never).

The fix is to make the never check happen before the tuple-shape test, e.g. [DigitsToCounter<Digits>] extends [never] ? never : ..., so a non-numeric $… token falls through to PlaceholderName and gets its own sequential slot like @name already does.

Why it matters

README, Limitations: "Numbered placeholders bind by their index ($2 fills the second tuple slot even when it appears first); a repeated $n occupies a single slot." A cast attached to the placeholder silently breaks that rule, and the failure mode (never) is not diagnosable from the call site — the error just says the argument is not assignable to never.

Found in an audit of master @ dc1afdb (v0.1.8). No existing test covers a cast on a placeholder or a $name placeholder.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions