SortableField<F>
Type-safe end to end
Field names are literal types inferred from the schema; operator/value pairs are checked at compile time — between wants [T, T], isNull accepts no value, enumField narrows to a union.
products.sort("pricee")URL ⇄ QuerySpec ⇄ rows
Type-safe filtering, progressive multi-sort and pagination in one canonical, backend-safe QuerySpec. Zero dependencies · 7.4 kB · never throws.
{ "filters": [ { "kind": "condition", "field": "price", "op": "between", "value": [ 1000, 5000 ] }, { "kind": "condition", "field": "deletedAt", "op": "isNull" } ], "sorts": [ { "field": "price", "direction": "desc", "priority": 1 }, { "field": "name", "direction": "asc", "priority": 2 } ] }
| Blade 16 | laptop | 2,399 | 4.9 | 388 | 3 |
| Blade 14 | laptop | 1,899 | 4.8 | 911 | 5 |
| Blade 14 Air | laptop | 1,899 | 4.5 | 640 | 11 |
| Aria 12 Pro | phone | 1,299 | 4.8 | 911 | 17 |
Click a header to sort · shift+click to thenSort · click again to toggle direction. Every result on this page comes from the real package at runtime.
$ npm install querypipesort · parse · canonicalize · guards
| Aria 12 | phone | 999 | 4.6 | 1284 | 42 |
| Aria 12 Pro | phone | 1299 | 4.8 | 911 | 17 |
| Aria Mini | phone | 649 | 4.1 | 208 | 0 |
| Aria 11 | phone | 899 | 4.6 | 731 | 13 |
| Blade 14 | laptop | 1899 | 4.8 | 911 | 5 |
| Blade 16 | laptop | 2399 | 4.9 | 388 | 3 |
| Blade 14 Air | laptop | 1899 | 4.5 | 640 | 11 |
| Slate 11 | tablet | 749 | 4.3 | 530 | 9 |
| Slate 11 Max | tablet | 999 | 4.3 | 530 | 21 |
| Slate 8 | tablet | 449 | 3.9 | 102 | 0 |
→ sort=category:asc
sort resets, thenSort refines — priorities renormalize to 1..n. Click a header to reset the chain, shift+click to append. The 1899 price tie and the 4.3-rating/530-review tie make the refinement visible.
NOT_SORTABLE secret @ 5
not sortable
UNKNOWN_FIELD nope @ 16
unknown field
INVALID_OPERATOR price like @ 38
unknown operator
parsenever throws. You get every error, with positions, in one pass — the wavy underlines come from each error’s position field.
Lenient in: segments in any order, direction optional. Press Canonicalize to get the byte-stable form.
Canonical order is fixed — sort → filter → offset → limit → cursor → version — so equal queries are equal strings: perfect cache keys and ETags. Highlighted characters were reordered or added.
sort=price:asc,rating:asc
ok — 2 sort columns accepted
Backend-safe by default: whitelist + limits, before your database ever sees the query. Guards also cap filters, value length and in list size.
createQuery({ fields, stableBy, guards })
SortableField<F>
Field names are literal types inferred from the schema; operator/value pairs are checked at compile time — between wants [T, T], isNull accepts no value, enumField narrows to a union.
products.sort("pricee")thenSort
sort resets, thenSort appends without disturbing prior priorities, insertSortAfter / removeSort / toggleSortDirection edit the chain in place. Priorities renormalize to 1..n on every step.
guards
A mandatory field whitelist (there is no schema-less parse), guard limits on sort columns, filter count, value length and in-list size — and no user input ever becomes a RegExp.
stableBy
Byte-stable canonical stringify, stable sort with an auto-injected unique tie-breaker, and one fixed SQL ORDER BY reference model (PostgreSQL NULL semantics) for client apply and future adapters.
0 deps
Zero runtime dependencies, ESM + CJS with correct types for both, fully tree-shakable, ≈7.4 kB min+gzip. Runs on Node ≥ 18.17 and any modern browser.
QueryResult<T>
parse and validate return a multi-error Result — every problem, with codes and positions, in one pass. Only schema/builder misuse throws TypeError; the data path never does.
the bridge, not a competitor
querypipe doesn’t compete with nuqs, TanStack Table or your ORM — it’s the typed contract between them. It manages the query, never the data.
| Concern | Owned by | querypipe’s role |
|---|---|---|
| URL state persistence | nuqs, URLSearchParams | Produces/consumes the canonical query string they store |
| Table UI state | TanStack Table | Two-way adapter (v0.2): table state ⇄ the same QuerySpec |
| Mongo query parsing | api-query-params | Compat dialect (v0.2): parse their syntax → canonical spec |
| ORM execution | Prisma, Drizzle, raw SQL | Adapters (v0.2): QuerySpec → param-safe where/orderBy |
| The query contract | — | querypipe — one typed spec everyone agrees on |
parse → 400 | findMany
The inbound boundary is one call. Everything that comes back ok is whitelisted, guard-limited and injection-safe by construction; everything else is a ready-made 400 body.
import express from "express";
import { createQuery, dateField, enumField, isErr, numberField, stringField } from "querypipe";
const products = createQuery({
fields: {
id: numberField(),
name: stringField(),
price: numberField(),
category: enumField(["phone", "laptop", "tablet"]),
deletedAt: dateField(),
},
stableBy: "id",
guards: { maxSortColumns: 3 },
});
const app = express();
app.get("/products", async (req, res) => {
const parsed = products.parse(req.originalUrl.split("?")[1] ?? "");
if (isErr(parsed)) {
// Structured, machine-readable 400 — no try/catch anywhere.
res.status(400).json({ ok: false, errors: parsed.errors });
return;
}
// parsed.value is whitelisted, guard-limited and injection-safe by
// construction. Map it to your ORM by hand for now — official adapters
// (@querypipe/prisma, @querypipe/sql) ship in v0.2.
const rows = await db.product.findMany(toPrismaArgs(parsed.value));
res.json({ ok: true, rows });
});GET /products?sort=price:desc&filter=price:like:5&limit=abc
HTTP/1.1 400 · content-type: application/json
{ "ok": false, "errors": [ { "code": "INVALID_OPERATOR", "message": "unknown operator", "field": "price", "op": "like", "position": 29 }, { "code": "INVALID_VALUE", "message": "non-negative integer required", "field": "limit", "position": 42 } ] }
This response body is generated by running parse at build time — not typed by hand.
v0.2 → v0.3 → java
v0.2 · roadmap
v0.3 · roadmap
querypipe-java · roadmap
Newsletter signup is not enabled yet. Watch the GitHub repo for releases in the meantime.
Read the docs, or open the playground — its URL state is managed by querypipe itself.