Node.js cookbook · Beginner

Validate Express requests with Zod

Reject invalid bodies and query strings with clear 422 errors and get typed data in handlers.

Approach

Write a small middleware factory that parses req.body/req.query/req.params with Zod schemas, returns a Problem Details 422 with field errors on failure, and stores the parsed (coerced, typed) values for the handler.

Solution

import type { NextFunction, Request, Response } from "express";
import { z, type ZodType } from "zod";

export function validate<B extends ZodType, Q extends ZodType>(schemas: { body?: B; query?: Q }) {
  return (req: Request, res: Response, next: NextFunction) => {
    const errors: { field: string; message: string }[] = [];
    for (const [part, schema] of Object.entries(schemas) as [keyof typeof schemas, ZodType][]) {
      const result = schema.safeParse(req[part]);
      if (!result.success) {
        errors.push(...result.error.issues.map((i) => ({ field: [part, ...i.path].join("."), message: i.message })));
      } else {
        res.locals[part] = result.data;          // typed, coerced data
      }
    }
    if (errors.length) {
      return res.status(422).type("application/problem+json").json({ title: "Validation failed", status: 422, errors });
    }
    next();
  };
}

const CreateOrder = z.object({
  items: z.array(z.object({ sku: z.string().min(1), qty: z.number().int().positive() })).min(1).max(50),
  couponCode: z.string().trim().toUpperCase().optional(),
});
const ListOrders = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), cursor: z.string().optional() });

app.post("/api/orders", requireAuth, validate({ body: CreateOrder }), async (req, res) => {
  const body = res.locals.body as z.infer<typeof CreateOrder>;
  res.status(201).json(await orders.create(req.user!.id, body));
});
app.get("/api/orders", requireAuth, validate({ query: ListOrders }), async (req, res) => { /* res.locals.query */ });

Pitfalls

  • Trusting req.body types without runtime validation.
  • Forgetting express.json({ limit }) body size limits.
  • Returning only the first error.

Variations

  • Fastify uses JSON Schema natively (or fastify-type-provider-zod).
  • Generate OpenAPI from Zod with zod-openapi.

Further reading: Zod documentation

Reference: https://zod.dev/

More Node.js recipes