Validation#
Buntok provides request data validation using Zod schemas. Zod is already included when you install @buntok/core.
zValidator#
Middleware for validating request data with Zod schemas. Supports: body, query, params.
Body Validation#
import { zValidator, z } from "@buntok/core/middlewares/validator";
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(0).max(150).optional(),
});
app.post("/users", zValidator("body", userSchema), (ctx) => {
const data = ctx.valid("body");
// data is already validated, safe to use
return ctx.json({ created: true, ...data });
});Query Validation#
const paginationSchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
});
app.get("/users", zValidator("query", paginationSchema), (ctx) => {
const { page, limit } = ctx.valid("query");
return ctx.json({ page, limit });
});Params Validation#
const idSchema = z.object({ id: z.string().uuid() });
app.get("/users/:id", zValidator("params", idSchema), (ctx) => {
const { id } = ctx.valid("params");
return ctx.json({ id });
});Model Registry#
Register schemas once with app.model and reference them by name anywhere a schema is accepted (zValidator, zResponse, validate()):
app.model("UserBody", z.object({ name: z.string().min(1) }));
// or app.model({ UserBody: ..., UserIdParams: ... });
app.post("/users", zValidator("body", "UserBody"), (ctx) =>
ctx.json(ctx.valid("body"), 201)
);
const schema = app.getModel("UserBody"); // throws if not registeredNames are module-global - use unique names in tests. An unknown name throws at route registration: Unknown model "X". Register it with app.model("X", schema) before routes use it.
ZodCtx#
ZodCtx is a TypeScript type that gives you fully typed access to validated data in decorator-based controllers. It infers types directly from your Zod schemas - no manual type annotations needed.
Why ZodCtx?#
Without ZodCtx, you'd have to manually type every validated field:
// Without ZodCtx - manual types, easy to drift out of sync
app.post("/users", zValidator("body", userSchema), (ctx) => {
const data = ctx.valid<{ name: string; email: string }>("body");
// ^^^ manually duplicated type
});With ZodCtx, the type is inferred automatically from the schema:
// With ZodCtx - types auto-inferred from Zod schema
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
app.post("/users", zValidator("body", userSchema), (ctx: ZodCtx<{ body: typeof userSchema }>) => {
const data = ctx.valid("body");
// ^? { name: string; email: string } - auto-inferred!
});How It Works#
ZodCtx<T> accepts an object with optional body, query, and params keys. Each key accepts a Zod schema type (or a raw TypeScript type). The type system extracts the inferred type via Zod's internal _type property.
// ZodCtx infers types from Zod schemas
type MyCtx = ZodCtx<{
body: typeof userSchema; // z.object({...}) → inferred type
query: typeof paginationSchema; // z.object({...}) → inferred type
params: typeof idSchema; // z.object({...}) → inferred type
}>
// It also works with raw TypeScript types (no Zod)
type MyCtx = ZodCtx<{
body: { name: string; role: string }
}>Body Validation#
import { Controller, Post, Use, ZodCtx } from "@buntok/core";
import { zValidator, z } from "@buntok/core/middlewares/validator";
const createUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
});
@Controller("/users")
export class UserController {
@Post("/")
@Use(zValidator("body", createUserSchema))
async create(ctx: ZodCtx<{ body: typeof createUserSchema }>) {
const { name, email, password } = ctx.valid("body");
// ^? string ^? string ^? string - all auto-typed!
return ctx.json({ success: true, data: { name, email } });
}
}Query Validation#
const paginationSchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10),
search: z.string().optional(),
});
@Controller("/users")
export class UserController {
@Get("/")
@Use(zValidator("query", paginationSchema))
async list(ctx: ZodCtx<{ query: typeof paginationSchema }>) {
const { page, limit, search } = ctx.valid("query");
// ^? number ^? number ^? string | undefined
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
where: search ? { name: { contains: search } } : undefined,
});
return ctx.paginate(users, total, page, limit);
}
}Params Validation#
const idSchema = z.object({
id: z.string().uuid(),
});
@Controller("/users")
export class UserController {
@Get("/:id")
@Use(zValidator("params", idSchema))
async getById(ctx: ZodCtx<{ params: typeof idSchema }>) {
const { id } = ctx.valid("params");
// ^? string (UUID)
const user = await db.user.findUnique({ where: { id } });
if (!user) return ctx.error("User not found", 404);
return ctx.json(user);
}
}Multiple Validators#
Combine body, query, and params validation in a single endpoint:
const createPostSchema = z.object({
title: z.string().min(1),
content: z.string().min(10),
});
const postQuerySchema = z.object({
draft: z.coerce.boolean().default(false),
});
@Controller("/users/:userId/posts")
export class PostController {
@Post("/")
@Use(zValidator("params", z.object({ userId: z.string().uuid() })))
@Use(zValidator("body", createPostSchema))
@Use(zValidator("query", postQuerySchema))
async create(
ctx: ZodCtx<{
params: { userId: string };
body: typeof createPostSchema;
query: typeof postQuerySchema;
}>
) {
const { userId } = ctx.valid("params");
const { title, content } = ctx.valid("body");
const { draft } = ctx.valid("query");
const post = await db.post.create({
data: { title, content, draft, authorId: userId },
});
return ctx.json(post, 201);
}
}What ZodCtx Gives You#
ZodCtx extends the full Context - you get all context methods plus typed validation:
| Method | Description |
|---|---|
| ctx.valid("body") | Returns validated body (typed) |
| ctx.valid("query") | Returns validated query params (typed) |
| ctx.valid("params") | Returns validated route params (typed) |
| ctx.body() | Read and parse raw JSON body from request (no validation) |
| ctx.json(data) | JSON response |
| ctx.error(msg, status) | Error response |
| ctx.paginate() | Paginated response |
ZodCtx vs Context#
| Feature | Context | ZodCtx |
|---|---|---|
| Validation types | Manual: ctx.valid<Type>("body") | Auto-inferred: ctx.valid("body") |
| Works with | Both decorator & functional API | Both decorator & functional API |
| Runtime behavior | Same (Context instance) | Same (Context instance) |
| Recommended for | Functional API handlers | Decorator-based controllers |
Functional API#
ZodCtx works in functional handlers too, though you can also use manual types with ctx.valid<Type>():
// ZodCtx pattern (auto-inferred)
app.post("/users", zValidator("body", userSchema), (ctx: ZodCtx<{ body: typeof userSchema }>) => {
const data = ctx.valid("body"); // auto-typed
return ctx.json(data, 201);
});
// Manual type pattern (also works)
app.post("/users", zValidator("body", userSchema), (ctx) => {
const data = ctx.valid<{ name: string; email: string }>("body"); // manual type
return ctx.json(data, 201);
});ctx.valid()#
Retrieve data that has been validated by zValidator. With ZodCtx, the return type is auto-inferred. Without it, pass a type parameter manually.
// With ZodCtx (auto-inferred)
const data = ctx.valid("body");
// Without ZodCtx (manual type)
const data = ctx.valid<UserCreateDto>("body");Shortcuts#
Shortcut functions for common use cases:
// validateBody = zValidator("body", schema)
app.post("/users", validateBody(schema), handler);
// validateParams = zValidator("params", schema)
app.get("/users/:id", validateParams(idSchema), handler);validateBody and validateParams are deprecated. Use zValidator("body", schema) and zValidator("params", schema) instead - they provide full type inference via ZodCtx.Error Response#
When validation fails, Buntok automatically returns a 422:
{
"success": false,
"message": "Validation Failed",
"details": [
{
"field": "name",
"message": "Name is required and must be at least 1 character long."
},
{
"field": "email",
"message": "Invalid email"
}
]
}Custom Error Message#
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
});Custom Error Format#
The default error response format is:
{
"success": false,
"message": "Validation Failed",
"details": [
{ "field": "name", "message": "..." }
]
}zValidator() returns a 422 response directly, so it does not pass validation failures to app.onError(). Use a custom middleware if you need to transform the validator response format.app.onError() remains available for errors thrown by route handlers and other middleware:
import { Buntok } from "@buntok/core";
const app = new Buntok();
app.onError((error, ctx) => {
// Handle other errors
return ctx.json({
status: "error",
message: error.message,
}, 500);
});zValidator() are already responses and do not enter this handler.When to Use ZodCtx#
Use ZodCtx in decorator-based controllers where you want types auto-inferred from Zod schemas. Use manual types with ctx.valid<Type>() in functional handlers or when your schema doesn't match your runtime type.
// Decorator controller - use ZodCtx for auto-inference
@Controller("/users")
export class UserController {
@Post("/")
@Use(zValidator("body", createUserSchema))
async create(ctx: ZodCtx<{ body: typeof createUserSchema }>) {
const { name, email } = ctx.valid("body"); // typed automatically
}
}
// Functional handler - both work, ZodCtx is optional
app.post("/users", zValidator("body", createUserSchema), (ctx) => {
const data = ctx.valid<UserCreateInput>("body"); // manual type
return ctx.json(data);
});zValidator only supports "body", "query", and "params" targets. For headers validation, use a custom middleware or check ctx.request.headers directly.Combining Validators#
app.post("/users",
zValidator("body", userSchema),
zValidator("query", paginationSchema),
(ctx) => {
const body = ctx.valid("body");
const query = ctx.valid("query");
return ctx.json({ body, query });
}
);zValidator only validates a specific target (body, query, params, or headers). You can use multiple validators simultaneously.File Upload Validation#
For file uploads, zValidator supports multipart/form-data with z.file() (Zod v4+) to validate file fields directly:
const schema = z.object({
name: z.string().min(1),
avatar: z.file().mime(["image/png", "image/jpeg"]),
});
app.post("/upload", zValidator("body", schema, {
contentType: "multipart/form-data"
}), async (ctx) => {
const { name, avatar } = ctx.valid("body");
// avatar is a File object
return ctx.json({ name, type: avatar.type });
});Multiple Files#
Use z.array(z.file()) to validate multiple files with the same field name:
const schema = z.object({
title: z.string(),
images: z.array(z.file()).min(1).max(10),
});
app.post("/gallery", zValidator("body", schema, {
contentType: "multipart/form-data"
}), async (ctx) => {
const { title, images } = ctx.valid("body");
// images is File[]
return ctx.json({ title, count: images.length });
});zValidator with multipart/form-data validates and parses both text fields and file fields. For comprehensive file validation (size, magic bytes, MIME types) and storage, use handleUploads() or the uploader() middleware instead.