URL ⇄ QuerySpec ⇄ rows

One typed contract from URL to database.

Type-safe filtering, progressive multi-sort and pagination in one canonical, backend-safe QuerySpec. Zero dependencies · 7.4 kB · never throws.

parse → QuerySpecok
{
  "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
    }
  ]
}
apply(rows) — 4 of 10
Blade 16laptop2,3994.93883
Blade 14laptop1,8994.89115
Blade 14 Airlaptop1,8994.564011
Aria 12 Prophone1,2994.891117

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 querypipe
dependencies
0
min+gzip
≈7.4 kB
conformance tests
182

sort · parse · canonicalize · guards

Try the semantics

Aria 12phone9994.6128442
Aria 12 Prophone12994.891117
Aria Miniphone6494.12080
Aria 11phone8994.673113
Blade 14laptop18994.89115
Blade 16laptop23994.93883
Blade 14 Airlaptop18994.564011
Slate 11tablet7494.35309
Slate 11 Maxtablet9994.353021
Slate 8tablet4493.91020

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.

createQuery({ fields, stableBy, guards })

What the contract gives you

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")
TS2345: Argument of type '"pricee"' is not assignable to parameter of type 'SortableField<…>'.

thenSort

Progressive multi-sort, first-class

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

Backend-safe by default

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

Deterministic

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

Tiny & dependency-free

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>

Structured errors, never thrown

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

One spec everyone agrees on

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.

ConcernOwned byquerypipe’s role
URL state persistencenuqs, URLSearchParamsProduces/consumes the canonical query string they store
Table UI stateTanStack TableTwo-way adapter (v0.2): table state ⇄ the same QuerySpec
Mongo query parsingapi-query-paramsCompat dialect (v0.2): parse their syntax → canonical spec
ORM executionPrisma, Drizzle, raw SQLAdapters (v0.2): QuerySpec → param-safe where/orderBy
The query contractquerypipe — one typed spec everyone agrees on

parse → 400 | findMany

The server side

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.

server.ts — Express
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

Where this is going

v0.2 · roadmap

Generate & interop

  • @querypipe/prisma and @querypipe/sql adapters — QuerySpec → param-safe where/orderBy
  • Two-way TanStack Table adapter
  • Syntax dialects: page=, JSON:API, OData
  • Keyset / cursor pagination
Full quarterly plan →

v0.3 · roadmap

OR groups

  • orGroup(...) builder for nested and/or trees
  • The FilterGroup type and wire format already ship in v0.1 — this is a pure API unlock, zero wire bytes change
Full quarterly plan →

querypipe-java · roadmap

Same contract, second language

  • Java port validated against the identical JSON conformance vectors
  • JPA/Criteria + JDBC adapters
  • Byte-identical canonical strings across both languages
Full quarterly plan →

Get an email when v0.2 adapters land

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.