Validate Bodies and Params with Zod, Return 422 on Failure
Input Validation
Build a reusable validation middleware using Zod schemas that parses incoming request bodies and route params, and automatically responds with 422 when data is invalid.
What you'll learn
- Define Zod schemas for request body and route param shapes
- Create a validate() middleware factory that throws 422 on parse failure
- Apply the middleware per-route before the controller handler
Accepting whatever the client sends and passing it straight to the database is a reliability and security risk. Input validation rejects bad data early and returns a structured error so the client knows exactly what to fix.
Install Zod
npm install zod Define Schemas
Place schemas alongside the controller or in a dedicated schemas/ directory:
// src/schemas/articles.js
import { z } from "zod";
export const createArticleSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z.array(z.string()).optional(),
});
export const articleIdSchema = z.object({
id: z.coerce.number().int().positive(),
}); Validation Middleware Factory
A small factory function returns a Koa middleware that runs schema.safeParse()
and calls ctx.throw(422, …) on failure:
// src/middleware/validate.js
export function validateBody(schema) {
return async (ctx, next) => {
const result = schema.safeParse(ctx.request.body);
if (!result.success) {
ctx.throw(422, "Validation failed", {
errors: result.error.flatten().fieldErrors,
});
}
ctx.state.body = result.data; // attach the parsed, typed data
await next();
};
}
export function validateParams(schema) {
return async (ctx, next) => {
const result = schema.safeParse(ctx.params);
if (!result.success) {
ctx.throw(422, "Invalid params", {
errors: result.error.flatten().fieldErrors,
});
}
ctx.state.params = result.data;
await next();
};
} Applying Validation per Route
Pass the middleware before the controller handler in the route definition:
import { validateBody, validateParams } from "../middleware/validate.js";
import { createArticleSchema, articleIdSchema } from "../schemas/articles.js";
import { createArticle, getArticle } from "../controllers/articles.js";
router.post("/articles", validateBody(createArticleSchema), createArticle);
router.get("/articles/:id", validateParams(articleIdSchema), getArticle); In the controller, read the validated data from ctx.state instead of
ctx.request.body directly — it is already parsed and typed:
export async function createArticle(ctx) {
const data = ctx.state.body; // safe, typed by Zod
ctx.status = 201;
ctx.body = await ArticleService.create(data);
} 422 Response Shape
When validation fails, Koa’s central error handler receives the thrown error.
Configure it to forward ctx.throw’s third argument as the response body so
clients see field-level detail:
{ "message": "Validation failed", "errors": { "title": ["Required"] } } Up Next
Valid data is in — now learn how to paginate large collections with limit/offset and cursor strategies.
Pagination →