Input Validation

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.

4 min read Level 2/5 #koa#validation#zod
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 →