A small utility to parse and validate pagination + select + sort + filters from querystring-like objects using Zod v4, and to generate a response validator that automatically projects your dataSchema based on the requested select.
It is designed for Node.js HTTP stacks where query parameters arrive as strings (or string arrays). It outputs a typed, normalized structure you can map to your ORM/query builder.
- Supports LIMIT/OFFSET pagination (
limit+page). - Supports CURSOR pagination with cursor coercion based on
cursorProperty(number / string / ISO date string). - Supports field projection using
select, including wildcard expansion (*) when enabled. - Supports sorting with an allowlist of sortable fields.
- Supports a filter DSL with
$operators and nested AND/OR grouping. - Provides a response validator (
validatorSchema/responseSchema) to validate API responses against the projected schema.z.infer<typeof responseSchema>gives you key autocompletion narrowed to configuredselectablepaths. - Also exports a lightweight
select()utility for field-projection-only use cases. - Compatible with OpenAPI tooling (zod-openapi etc.).
This library does not bind DB queries automatically. It gives you a safe parsed structure; you decide how to map it to your data layer.
npm i zod-paginate
# or
pnpm add zod-paginate
# or
yarn add zod-paginateimport { z } from "zod";
import { paginate } from "zod-paginate";
const ModelSchema = z.object({
id: z.number(),
status: z.string(),
createdAt: z.date(),
meta: z.object({
score: z.number(),
}),
});
const { queryParamsSchema, validatorSchema, responseSchema } = paginate({
paginationType: "LIMIT_OFFSET",
dataSchema: ModelSchema,
selectable: ["id", "status", "createdAt", "meta.score"],
sortable: ["createdAt", "id"],
filterable: {
status: { type: "string", ops: ["$eq", "$ilike"] },
createdAt: { type: "date", ops: ["$btw", "$null", "$eq", "$gt", "$lte"] },
id: { type: "number", ops: ["$gt", "$in", "$eq"] },
"meta.score": { type: "number", ops: ["$gte", "$lte"] },
},
defaultSortBy: [{ property: "createdAt", direction: "DESC" }],
defaultLimit: 20,
maxLimit: 100,
defaultSelect: '*',
});
// Example querystring-like input
const parsed = queryParamsSchema.parse({
limit: "10",
page: "2",
sortBy: "createdAt:DESC",
select: "id,status",
"filter.status": "$ilike:act",
});
console.log(parsed.pagination);
// Pre-built response validator (uses defaultSelect)
// z.infer<typeof responseSchema> narrows data keys to selectable paths
responseSchema.parse({
data: [{ id: 1, status: "active", createdAt: new Date(), meta: { score: 42 } }],
pagination: { itemsPerPage: 20, totalItems: 1, currentPage: 1, totalPages: 1 },
});
// Or build a context-aware validator from the parsed request
const contextSchema = validatorSchema(parsed.pagination);zod-paginate is ORM/query-builder agnostic by design — it parses and validates query parameters but does not generate database queries. Adapters bridge the gap between the parsed output and your data layer.
| Adapter | Description | Link |
|---|---|---|
| zod-paginate-drizzle | Drizzle ORM adapter — automatically maps parsed pagination, filters, sorting, and select to Drizzle queries. | GitHub |
Returns:
queryParamsSchema: Zod schema to parse query objects (strings / string arrays).validatorSchema(parsed?): function returning a Zod schema to validate the response payload, projected based on the parsedselect.responseSchema: pre-built Zod schema for validating responses usingdefaultSelect(or all selectable fields). Equivalent to callingvalidatorSchema()with no arguments.
// Overload 1 — LIMIT_OFFSET
export function paginate<
TSchema extends DataSchema,
const TSelectable extends readonly AllowedPath<TSchema>[],
>(
config: CommonQueryConfigFromSchema<TSchema, TSelectable[number]> & { paginationType: "LIMIT_OFFSET" },
): PaginateResult<TSchema, TSelectable[number], "LIMIT_OFFSET">;
// Overload 2 — CURSOR
export function paginate<
TSchema extends DataSchema,
const TSelectable extends readonly AllowedPath<TSchema>[],
>(
config: CommonQueryConfigFromSchema<TSchema, TSelectable[number]> & CursorPaginationConfig<…>,
): PaginateResult<TSchema, TSelectable[number], "CURSOR">;Use PaginateResult<TSchema, TSelectable, TType> instead of ReturnType<typeof paginate> when you need an explicit return type — it preserves the generics so that z.infer<typeof responseSchema> correctly narrows both data keys and pagination metadata.
TType('LIMIT_OFFSET' | 'CURSOR'): When specified, narrows the response/payload types so you gettotalItems/totalPages(LIMIT_OFFSET) orcursor(CURSOR) without manual narrowing. Defaults to the union if omitted.
import { paginate, type PaginateResult } from "zod-paginate";
// TSelectable defaults to all paths if omitted, TType defaults to union
function createPaginator(): PaginateResult<typeof ModelSchema, "id" | "status", "LIMIT_OFFSET"> {
return paginate({
paginationType: "LIMIT_OFFSET",
dataSchema: ModelSchema,
selectable: ["id", "status"],
/* … */
});
}
// Without TType — pagination is still a union, but data keys are narrowed
function createPaginatorUnion(): PaginateResult<typeof ModelSchema, "id" | "status"> {
return paginate({ dataSchema: ModelSchema, selectable: ["id", "status"], /* … */ });
}| Option | Type | Description |
|---|---|---|
paginationType |
"LIMIT_OFFSET" | "CURSOR" |
Select pagination mode. |
dataSchema |
z.ZodObject |
Zod schema representing one data item returned by your API (used for projection + cursor inference). |
selectable? |
string[] (typed paths) |
Allowlist of selectable fields (dot paths supported). Enables select. |
sortable? |
string[] (typed paths) |
Allowlist of sortable fields. Enables sortBy. |
filterable? |
object | Allowlist of filterable fields and allowed operators + field type. |
defaultSortBy? |
{ property, direction }[] |
Default sort if sortBy missing/empty. |
defaultLimit |
number |
Required. Default limit if limit missing. |
maxLimit |
number |
Required. Rejects limit values above this. |
defaultSelect |
field[] | "*" |
Required. Default select if select missing. "*" expands to selectable. |
cursorProperty |
(CURSOR only) typed path | The field used for cursor paging. Cursor type is inferred from dataSchema at that path and the query input cursor is coerced accordingly. |
queryParamsSchema accepts any record-like input:
Record<string, unknown>Typical querystring parsers produce values like:
"10"(string)["a", "b"](repeated query params)- everything else is ignored / treated as undefined
- Input: string numeric (e.g.
"10") - Output: number
- Rules
- Must be a numeric string
- Must be
<= maxLimitif configured - Falls back to
defaultLimitwhen missing
- Input: string numeric (e.g.
"2") - Output: number
- Rules
- Only valid when
paginationType: "LIMIT_OFFSET" - Forbidden in CURSOR mode
- Only valid when
- Input: string (querystring input is always string)
- Output:
number | string(coerced) - Rules
- Only valid when
paginationType: "CURSOR" - Forbidden in LIMIT_OFFSET mode
- If provided, it is coerced based on the Zod type of
cursorPropertyindataSchema:z.number()field →"123"becomes123(integer-only)z.string()field →"abc"stays"abc"z.date()field → must be ISO date or ISO datetime, stays a string ("2022-01-01"or"2022-01-01T12:00:00Z")
- Only valid when
- Input: string or string[]
- Output:
[{ property, direction }] - Rules
- Requires
sortablein config - Format:
field:ASCorfield:DESC - Empty items are ignored
- If missing (or becomes empty after cleanup), falls back to
defaultSortByif configured - Properties are matched against the allowlist (unknown fields are dropped)
- Requires
- Input: string
- Output: string[] (typed paths)
- Rules
- Requires
selectablein config - string is split by
,, trimmed, empty items removed *expands to the configuredselectableallowlist- If missing, falls back to
defaultSelectif configured select=(empty) is rejected- Unknown fields are rejected at parse-time (strict allowlist)
- Requires
Filters are passed as query keys with this pattern:
filter.<field>=<dsl>Where <field> is a dot-path field (example: meta.score).
You configure which fields are filterable and which operators are allowed via filterable.
| Operator | Meaning | Value format |
|---|---|---|
$eq |
equals | number / string / ISO date depending on field type |
$null |
is null | no value |
$in |
in list | a,b,c (comma-separated) |
$contains |
contains values | a,b,c (comma-separated) |
$gt |
greater than | number or ISO date |
$gte |
greater than or equal | number or ISO date |
$lt |
less than | number or ISO date |
$lte |
less than or equal | number or ISO date |
$btw |
between | a,b where both are numbers OR both are ISO dates |
$ilike |
case-insensitive contains (string) | string |
$sw |
starts with (string) | string |
Matches rows where the field is exactly equal to the given value. The value type must match the field type (number, string, or ISO date).
filter.status=$eq:active
filter.id=$eq:42
filter.createdAt=$eq:2025-01-15Matches rows where the field is NULL. No value is required after the operator.
filter.deletedAt=$nullTo match rows where the field is not null, combine with $not:
filter.deletedAt=$not:$nullMatches rows where the field value is one of the provided comma-separated values.
filter.status=$in:active,pending,review
filter.id=$in:1,2,3,10Matches rows where the field (typically an array column) contains all the provided comma-separated values.
filter.tags=$contains:typescript,zod
filter.roles=$contains:adminStandard comparison operators: greater than, greater than or equal, less than, less than or equal. Works with numbers and ISO dates.
filter.id=$gt:100
filter.id=$lte:500
filter.createdAt=$gte:2025-01-01
filter.createdAt=$lt:2025-06-01T00:00:00ZCombine multiple comparisons to build ranges:
filter.id=$gt:10&filter.id=$lt:100Matches rows where the field value falls between two bounds (inclusive). Both bounds must be the same type — either both numbers or both ISO dates.
filter.id=$btw:10,100
filter.createdAt=$btw:2025-01-01,2025-12-31
filter.createdAt=$btw:2025-01-01T00:00:00Z,2025-06-30T23:59:59ZMatches rows where the string field contains the given substring, ignoring case. Useful for search-style filtering.
filter.status=$ilike:act
filter.name=$ilike:john
filter.email=$ilike:@example.comMatches rows where the string field starts with the given prefix.
filter.name=$sw:Jon
filter.email=$sw:admin@
filter.path=$sw:/api/v2Runtime validation enforces:
- field allowlist (
filterable) - operator allowlist per field (
ops) - value type compatibility (number vs date vs string)
If the filter does not start with $, it is interpreted as $eq:<value>.
Prefix any operator with $not: to negate the condition.
Examples:
filter.createdAt=$not:$null
filter.status=$not:$eq:activeUse repeated query params:
filter.id=$gt:10&filter.id=$lt:100Or in object form:
{
"filter.id": ["$gt:10", "$lt:100"]
}Groups let you build nested AND/OR boolean logic.
There are two layers:
- Combine multiple conditions inside the same group
- Build a group tree (attach groups as children of other groups)
Prefix any filter DSL with:
$g:<groupId>:Within a group, the first condition cannot have $and/$or. All following conditions may be prefixed with $and or $or.
To nest groups, define these query keys:
group.<id>.parent— parent group id (integer string)group.<id>.join— how this group is joined to its parent ($andor$or)group.<id>.op— default join used when combining this group's children (optional)
Rules:
- Root group id is always
"0". group.0.parentandgroup.0.joinare forbidden.- Cycles are rejected.
- Child groups are resolved in numeric order (deterministic).
validatorSchema(parsed) returns a Zod schema you can use to validate your API response.
What it does:
- Uses the effective
select(explicitselect, elsedefaultSelect, else full schema) to project the item schema. - Validates cursor type (CURSOR mode) based on
cursorProperty. - Enforces mode-specific pagination metadata shape.
LIMIT/OFFSET mode:
{
data: Array<ProjectedItem>,
pagination: {
itemsPerPage: number,
totalItems: number,
currentPage: number,
totalPages: number,
sortBy?: Array<{ property: string, direction: "ASC" | "DESC" }>,
filter?: WhereNode
}
}CURSOR mode:
{
data: Array<ProjectedItem>,
pagination: {
itemsPerPage: number,
cursor: number | string | Date,
sortBy?: Array<{ property: string, direction: "ASC" | "DESC" }>,
filter?: WhereNode
}
}Notes:
ProjectedItemis computed fromdataSchema+ the effectiveselect.- If
cursorPropertypoints to az.number()field,pagination.cursormust be a number. - If
cursorPropertypoints to az.string()field,pagination.cursormust be a string. - If
cursorPropertypoints to az.date()field, this library accepts an ISO string or aDate(depending on implementation).
You can call validatorSchema() without arguments to build a validator based on defaults (defaultSelect, cursorProperty, etc.).
HTTP query:
?limit=20&page=1&select=id,status,createdAt&sortBy=createdAt:DESC&filter.status=$ilike:act&filter.id=$gt:10Parsing:
const parsed = queryParamsSchema.parse({
limit: "20",
page: "1",
select: "id,status,createdAt",
sortBy: "createdAt:DESC",
"filter.status": "$ilike:act",
"filter.id": "$gt:10",
});
// parsed.pagination
// {
// type: "LIMIT_OFFSET",
// limit: 20,
// page: 1,
// select: ["id", "status", "createdAt"],
// sortBy: [{ property: "createdAt", direction: "DESC" }],
// filters: { type: "and", items: [...] } // WhereNode AST
// }Config:
const { queryParamsSchema } = paginate({
paginationType: "CURSOR",
dataSchema: ModelSchema,
cursorProperty: "id", // id is z.number()
selectable: ["id", "status", "createdAt"],
defaultSelect: ["id", "createdAt"],
});Parsing:
const parsed = queryParamsSchema.parse({ cursor: "123", limit: "10" });
// parsed.pagination
// {
// type: "CURSOR",
// limit: 10,
// cursor: 123, // <- coerced from "123" because cursorProperty is a number
// cursorProperty: "id",
// select: ["id", "createdAt"]
// }Goal: (status == active OR status == postponed) AND (id > 10)
const parsed = queryParamsSchema.parse({
"filter.status": ["$g:1:$eq:active", "$g:1:$or:$eq:postponed"],
"filter.id": "$g:2:$gt:10",
"group.1.parent": "0",
"group.2.parent": "0",
"group.2.join": "$and",
});
// parsed.pagination.filters
// {
// type: "and",
// items: [
// { type: "or", items: [ ...status filters... ] },
// { type: "filter", field: "id", condition: { op: "$gt", value: 10, ... } }
// ]
// }Using the pre-built responseSchema (based on defaultSelect):
const { responseSchema } = paginate({
paginationType: "LIMIT_OFFSET",
dataSchema: ModelSchema,
selectable: ["id", "status", "createdAt", "meta.score"],
defaultSelect: '*',
defaultLimit: 20,
maxLimit: 100,
});
// Validate without parsing a request first
responseSchema.parse({
data: [{ id: 1, status: "active", createdAt: new Date(), meta: { score: 42 } }],
pagination: { itemsPerPage: 20, totalItems: 1, currentPage: 1, totalPages: 1 },
});
// Type-safe: z.infer narrows data keys to selectable paths
type Response = z.infer<typeof responseSchema>;
// Response["data"][0] → { id?: unknown; status?: unknown; createdAt?: unknown; meta?: unknown }
// Response["pagination"] → LimitOffsetPaginationResponseMeta (not a union!)
// Response["pagination"].totalItems → number ✓ (no manual narrowing needed)Or using validatorSchema(parsed) for request-aware projection:
const parsed = queryParamsSchema.parse({ select: "id,status", limit: "10", page: "1" });
const contextSchema = validatorSchema(parsed.pagination);
// contextSchema expects data items shaped like { id, status } only
contextSchema.parse({
data: [{ id: 1, status: "active" }],
pagination: { itemsPerPage: 10, totalItems: 1, currentPage: 1, totalPages: 1 },
});If you only need field projection without pagination, sorting, or filters, you can use the select() utility directly.
import { select } from "zod-paginate";
export function select<
TSchema extends DataSchema,
const TSelectable extends readonly AllowedPath<TSchema>[],
>(
config: SelectConfig<TSchema, TSelectable[number]>,
): SelectResult<TSchema, TSelectable[number]>;Returns:
queryParamsSchema: Zod schema to parse{ select: "id,name" }into{ select: ["id", "name"] }.validatorSchema(parsed?): function returning a Zod schema expecting{ data: Array<ProjectedItem> }.responseSchema: pre-built Zod schema for validating responses usingdefaultSelect(or all selectable fields).z.infer<typeof responseSchema>narrows data keys to the configuredselectablepaths.
Use SelectResult<TSchema, TSelectable> instead of ReturnType<typeof select> for explicit return types:
import { select, type SelectResult } from "zod-paginate";
function createSelector(): SelectResult<typeof ProductSchema, "id" | "name" | "price"> {
return select({ dataSchema: ProductSchema, selectable: ["id", "name", "price"], /* … */ });
}| Option | Type | Description |
|---|---|---|
dataSchema |
z.ZodObject |
Zod schema representing one data item. |
selectable |
string[] (typed paths) |
Allowlist of selectable fields (dot paths supported). |
defaultSelect |
field[] | "*" |
Required. Default select if select is missing. "*" expands to selectable. |
import { z } from "zod";
import { select } from "zod-paginate";
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number(),
details: z.object({
weight: z.number(),
color: z.string(),
}),
});
const { queryParamsSchema, validatorSchema, responseSchema } = select({
dataSchema: ProductSchema,
selectable: ["id", "name", "price", "details.weight", "details.color"],
defaultSelect: ["id", "name", "price"],
});
// select=* expands to all selectable fields
const parsed = queryParamsSchema.parse({ select: "*" });
// parsed.select → ["id", "name", "price", "details.weight", "details.color"]
// With specific fields
const parsed2 = queryParamsSchema.parse({ select: "id,name,details.color" });
// parsed2.select → ["id", "name", "details.color"]
// Pre-built response validator (based on defaultSelect)
responseSchema.parse({
data: [{ id: 1, name: "Widget", price: 9.99 }],
});
// Or context-aware validator from parsed request
const contextSchema = validatorSchema(parsed2);
contextSchema.parse({
data: [
{ id: 1, name: "Widget", details: { color: "red" } },
{ id: 2, name: "Gadget", details: { color: "blue" } },
],
});
// Missing select → uses defaultSelect
const parsed3 = queryParamsSchema.parse({});
// parsed3.select → ["id", "name", "price"]