Skip to content

fix(params): stop accepting null for a comparison parameter - #333

Merged
tiagolauer merged 1 commit into
masterfrom
fix/299-nullable-param
Aug 2, 2026
Merged

fix(params): stop accepting null for a comparison parameter#333
tiagolauer merged 1 commit into
masterfrom
fix/299-nullable-param

Conversation

@tiagolauer

Copy link
Copy Markdown
Owner

Fixes #299.

The bug

// schema: email: string | null
Params<DB, 'select id from users where email = $1'>  // [string | null]
db.query('select id from users where email = $1', null); // compiles, matches nothing

Join-induced nullability leaks the same way, and shows the mechanism plainly — the | null belongs to the result side (a missing match makes the column null in the output), not to the value you compare against:

Params<DB, 'select u.id from users u left join posts p on u.id = p.user_id where p.views = $1'>
// [number | null]

The fix

ParamType resolves through ResolveColumnLoose, which reuses result-column resolution verbatim. Comparison parameters are now NonNullable, with three carve-outs where null is meaningful:

  • is distinct from — comparing against null is the entire point of it.
  • SET assignments and VALUES tuples — storing null is ordinary. The scan keeps only the two previous tokens, so set a = $1, b = $2 gives the second placeholder no local hint that it is still a write; the region is carried along the scan instead.
  • an unresolved column — stays unknown. NonNullable<unknown> is {}, which would have rejected the null such a slot is still allowed to take. This one was caught by an existing test (tests/update-where-subquery.test-d.ts) going red mid-change, not by foresight.

Verification

tests/nullable-params.test-d.ts, eleven cases. Four red on master:

tests/nullable-params.test-d.ts(16,3): error TS2344: Type 'false' does not satisfy the constraint 'true'.
tests/nullable-params.test-d.ts(20,3): ...
tests/nullable-params.test-d.ts(26,3): ...
tests/nullable-params.test-d.ts(36,3): ...
  • =, like, in, and the LEFT JOIN column all drop the null
  • carve-outs pinned: is distinct from, update ... set, every assignment in a multi-column SET, and insert ... values
  • controls: a non-nullable column is unchanged, = any($1) still wraps to number[], limit $1 is still number

tsc --noEmit clean, 192 runtime tests pass. Budget 193,992 (+1,506).

    // email: string | null
    Params<DB, 'select id from users where email = $1'>  // was [string | null]
    db.query('select id from users where email = $1', null); // compiled, matched nothing

`= NULL` is never true, so the type layer was signing off on a query that
silently returns no rows - the class of mistake this library exists to catch.
The LEFT JOIN case shows where the null actually comes from: `| null` on a
joined column means a missing match makes it null *in the output*, which says
nothing about what you may compare it to.

Comparison parameters are now NonNullable. Three carve-outs, because null is
meaningful in them:

- `is distinct from`, whose whole purpose is comparing against null
- SET assignments and VALUES tuples, where storing null is ordinary. The scan
  keeps only the two previous tokens, so `set a = $1, b = $2` gives the second
  placeholder no local hint that it is still a write - the region is carried
  along the scan instead.
- an unresolved column, which stays `unknown`: NonNullable<unknown> is `{}`,
  which would have rejected the null such a slot is still allowed to take.

Costs 1,506 instantiations (192,486 -> 193,992, 97% of the budget).

Fixes #299

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tiagolauer
tiagolauer force-pushed the fix/299-nullable-param branch from 7278a7a to 91e3d92 Compare August 2, 2026 13:03
@tiagolauer
tiagolauer merged commit 560aca3 into master Aug 2, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Params for nullable columns accept null, which can never match

1 participant