Validation

Zod at the Boundary — Trust Inside

Validation

Validate request bodies, params, and query at the edge. Zod is the standard tool in modern Node.

4 min read Level 2/5 #express#validation#zod
What you'll learn
  • Define Zod schemas
  • Reuse a validate middleware
  • Return clean validation errors

Untrusted input is the root of most vulnerabilities. Validate at the boundary — before any business logic touches the data.

Install Zod

npm install zod

A Schema

import { z } from "zod";

export const CreateUserSchema = z.object({
  email:    z.string().email(),
  password: z.string().min(8).max(72),
  name:     z.string().min(1).max(60),
  age:      z.number().int().min(13).max(120).optional(),
});

A Validate Middleware

// src/middleware/validate.js
export function validate({ body, params, query }) {
  return (req, res, next) => {
    if (body) {
      const r = body.safeParse(req.body);
      if (!r.success) return badRequest(res, r.error);
      req.validBody = r.data;
    }
    if (params) {
      const r = params.safeParse(req.params);
      if (!r.success) return badRequest(res, r.error);
      req.validParams = r.data;
    }
    if (query) {
      const r = query.safeParse(req.query);
      if (!r.success) return badRequest(res, r.error);
      req.validQuery = r.data;
    }
    next();
  };
}

function badRequest(res, error) {
  res.status(400).json({
    error: {
      code: "validation_failed",
      issues: error.issues.map((i) => ({
        path: i.path.join("."),
        message: i.message,
      })),
    },
  });
}

Use It

import { validate } from "../middleware/validate.js";
import { CreateUserSchema } from "../schemas/users.js";

router.post("/", validate({ body: CreateUserSchema }), userController.create);

Inside userController.create, req.validBody is fully typed and guaranteed valid.

Validate Params + Query

import { z } from "zod";

const IdParam = z.object({ id: z.coerce.number().int().positive() });
const ListQuery = z.object({
  page:  z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

router.get("/",
  validate({ query: ListQuery }),
  userController.list
);

router.get("/:id",
  validate({ params: IdParam }),
  userController.get
);

z.coerce turns the string "42" into the number 42. Magic.

Error Format

The standard issue shape after our badRequest helper:

{
  "error": {
    "code": "validation_failed",
    "issues": [
      { "path": "email", "message": "Invalid email" },
      { "path": "password", "message": "String must contain at least 8 character(s)" }
    ]
  }
}

Frontends can render each issue next to its form field.

Why Not Just TypeScript?

TS types vanish at compile time. An attacker sending arbitrary JSON slips past TS. Zod (or any runtime schema) is the only thing that catches them at runtime.

In practice: TS types derived from Zod schemas — one source of truth:

type CreateUser = z.infer<typeof CreateUserSchema>;
Pagination →