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.
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 intonever, so the query becomes impossible to call.Two shapes hit this, and both are ordinary SQL:
where created_at > $1::timestamptzwhere id = $id(thenode:sqliteadapter binds$name,@nameand:name; seeSQLITE_PARAM_PREFIXESinsrc/adapters/node-sqlite.ts)Reproduction
Schema
{ users: { id: number; name: string; created_at: Date; deleted_at: string | null }, orders: { ... } }.Params<DB, Q>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]@path is correct)[never]means the call site can never be satisfied: there is no value assignable tonever.Root cause
src/params.ts:For
$1::int,Digitsis"1::int"andDigitsToCounterresolves tonever. Butnever extends [unknown, ...infer Position]is true (never is assignable to everything), soPlaceholderPositiondoes not fall through to the named branch — it returns a bogus position andAddParamroutes the token into theindexedbucket:SetSlotthen writes every such placeholder to the same slot, intersecting the types (number & string=never).The fix is to make the
nevercheck happen before the tuple-shape test, e.g.[DigitsToCounter<Digits>] extends [never] ? never : ..., so a non-numeric$…token falls through toPlaceholderNameand gets its own sequential slot like@namealready does.Why it matters
README, Limitations: "Numbered placeholders bind by their index (
$2fills the second tuple slot even when it appears first); a repeated$noccupies 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 tonever.Found in an audit of
master@ dc1afdb (v0.1.8). No existing test covers a cast on a placeholder or a$nameplaceholder.