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#

typescript
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#

typescript
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#

typescript
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()):

typescript
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 registered

Names 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:

typescript
// 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:

typescript
// 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.

typescript
// 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#

typescript
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#

typescript
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#

typescript
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:

typescript
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:

MethodDescription
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#

FeatureContextZodCtx
Validation typesManual: ctx.valid<Type>("body")Auto-inferred: ctx.valid("body")
Works withBoth decorator & functional APIBoth decorator & functional API
Runtime behaviorSame (Context instance)Same (Context instance)
Recommended forFunctional API handlersDecorator-based controllers

Functional API#

ZodCtx works in functional handlers too, though you can also use manual types with ctx.valid<Type>():

typescript
// 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.

typescript
// 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:

typescript
// validateBody = zValidator("body", schema)
app.post("/users", validateBody(schema), handler);

// validateParams = zValidator("params", schema)
app.get("/users/:id", validateParams(idSchema), handler);
Warning
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:

typescript
{
  "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#

typescript
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:

typescript
{
  "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:

typescript
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);
});
Info
The error handler receives the thrown error and context. Validation failures returned by 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.

typescript
// 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);
});
Info
Note: zValidator only supports "body", "query", and "params" targets. For headers validation, use a custom middleware or check ctx.request.headers directly.

Combining Validators#

typescript
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 });
  }
);
Info
Each 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:

typescript
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:

typescript
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 });
});
Info
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.

Next Steps#

  • Upload - Validate file uploads
  • Routing - Route parameters and query strings