Zod schema builders for @woltz/rich-domain Criteria pattern. Framework-agnostic validation for filters, ordering, pagination, and search.
npm install @woltz/rich-domain-criteria-zod
npm install @woltz/rich-domain zod
import {
defineFilters,
CriteriaQuerySchema,
PaginatedResponseSchema,
} from "@woltz/rich-domain-criteria-zod";
import { z } from "zod";
// 1. Define filterable fields
const filters = defineFilters((f) => ({
name: f.string(),
email: f.email(),
age: f.number(),
isActive: f.boolean(),
createdAt: f.date(),
tags: f.array.string(),
}));
// 2. Create query schema with orderable fields whitelist
const querySchema = CriteriaQuerySchema(filters, {
orderBy: ["name", "createdAt", "age"] as const,
pagination: {
defaultLimit: 20,
maxLimit: 100,
},
});
// 3. Define response schema
const UserDto = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
const responseSchema = PaginatedResponseSchema(UserDto);
import Fastify from "fastify";
import { ZodTypeProvider } from "fastify-type-provider-zod";
const app = Fastify().withTypeProvider<ZodTypeProvider>();
app.route({
method: "GET",
url: "/users",
schema: {
querystring: querySchema,
response: { 200: responseSchema },
},
handler: async (request) => {
const criteria = Criteria.fromQueryParams(request.query);
return userService.list(criteria);
},
});
app.get("/users", (req, res) => {
const result = querySchema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const criteria = Criteria.fromQueryParams(result.data);
// ...
});
import { zValidator } from "@hono/zod-validator";
app.get("/users", zValidator("query", querySchema), (c) => {
const query = c.req.valid("query");
const criteria = Criteria.fromQueryParams(query);
// ...
});
export const userRouter = router({
list: publicProcedure.input(querySchema).query(({ input }) => {
const criteria = Criteria.fromQueryParams(input);
return userService.list(criteria);
}),
});
defineFilters(builder)Defines filterable fields with their types and operators.
const filters = defineFilters((f) => ({
// Basic types
name: f.string(),
email: f.email(),
age: f.number(),
isActive: f.boolean(),
createdAt: f.date(),
// Arrays
tags: f.array.string(),
scores: f.array.number(),
roles: f.array.enum(["admin", "user", "guest"]),
// Custom operators (restrict available operators)
status: f.string({ operators: ["equals", "in"] }),
// Nested paths
["author.name"]: f.string(),
}));
| Method | Operators |
|---|---|
f.string() |
equals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull |
f.email() |
Same as string (with email validation) |
f.number() |
equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, in, notIn, between, isNull, isNotNull |
f.date() |
Same as number |
f.boolean() |
equals, notEquals, isNull, isNotNull |
f.array.* |
in, notIn, isNull, isNotNull |
CriteriaQuerySchema(filters, options?)Creates a complete query schema with filters, ordering, pagination, and search.
const querySchema = CriteriaQuerySchema(filters, {
// Whitelist of orderable fields (required for type safety)
orderBy: ["name", "createdAt"] as const,
// Pagination defaults
pagination: {
defaultPage: 1,
defaultLimit: 20,
maxLimit: 100,
},
});
Why whitelist for orderBy?
Not all filterable fields should be orderable:
PaginatedResponseSchema(itemSchema)Creates a response schema matching PaginatedResult.toJSON() output.
const responseSchema = PaginatedResponseSchema(UserDto);
// Inferred type:
// {
// data: User[];
// meta: {
// page: number;
// limit: number;
// total: number;
// totalPages: number;
// };
// }
The schema accepts query strings in this format:
GET /users?name:contains=john&age:greaterThan=18&orderBy=name:asc&page=1&limit=20
# String
?name:equals=John
?name:contains=ohn
?name:startsWith=Jo
?name:endsWith=hn
?name:in=John,Jane,Bob
?name:isNull=true
# Number
?age:equals=25
?age:greaterThan=18
?age:lessThanOrEqual=65
?age:between=18,65
?price:in=10,20,30
# Date
?createdAt:greaterThan=2024-01-01
?createdAt:between=2024-01-01,2024-12-31
# Boolean
?isActive:equals=true
# Single field
?orderBy=name:asc
?orderBy=createdAt:desc
# Multiple fields
?orderBy=name:asc,createdAt:desc
?page=1&limit=20
# Or as object
?pagination={"page":1,"limit":20}
?search=john
import { InferCriteriaQuery, OrderEnum } from "@woltz/rich-domain-criteria-zod";
// Infer query type
type UserQuery = InferCriteriaQuery<typeof querySchema>;
// orderBy is typed as union:
// "name:asc" | "name:desc" | "createdAt:asc" | "createdAt:desc" | ...
// Create order type from fields
type UserOrder = OrderEnum<["name", "createdAt"]>;
// "name:asc" | "name:desc" | "createdAt:asc" | "createdAt:desc"
MIT