Skip to content
Express.js ex validation 5 min read

Type-Safe Validation with Zod

Zod is a TypeScript-first schema declaration and validation library that lets you define the shape of your data once and get both runtime validation and static types from a single source of truth. In an Express application this is a perfect fit: you write a schema that describes a valid request, validate the incoming payload at runtime, and let TypeScript infer the exact type of the validated data so the rest of your handler is fully typed. This page shows how to build Zod schemas, validate requests with safeParse inside reusable middleware, infer types with z.infer, and return structured, client-friendly validation errors.

Installing Zod

Zod ships as a single dependency with no peer requirements. Install it alongside your Express and TypeScript tooling.

npm install zod
npm install --save-dev typescript @types/express @types/node

Zod works at runtime in plain JavaScript too, but the type inference benefits only apply in TypeScript projects, which is where it shines.

Defining a schema

A Zod schema is built by composing primitives such as z.string() and z.number() into an object with z.object(). Each field can carry constraints (min, max, email, regex) and a custom error message. The schema below describes the body of a “create user” request.

import { z } from "zod";

export const createUserSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("A valid email is required"),
  age: z.number().int().min(18, "Must be 18 or older").optional(),
  role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
});

Because the schema is the single source of truth, you can derive the corresponding TypeScript type instead of declaring an interface by hand.

export type CreateUserInput = z.infer<typeof createUserSchema>;
// {
//   name: string;
//   email: string;
//   age?: number | undefined;
//   role: "admin" | "editor" | "viewer";
// }

Note: z.infer reflects the output type after parsing. Fields with .default() are required in the output even though they are optional in the input, because Zod fills them in. Use z.input<typeof schema> when you need the pre-parse shape.

Validating with safeParse

Zod offers two parsing methods. parse throws a ZodError on failure, while safeParse returns a discriminated result object with a success flag and either data or error. In an Express handler safeParse is usually preferable because it lets you control the response without a try/catch.

const result = createUserSchema.safeParse(req.body);

if (!result.success) {
  // result.error is a ZodError
} else {
  // result.data is fully typed as CreateUserInput
}

A reusable validation middleware

Rather than repeat parsing logic in every route, wrap it in a generic middleware factory. It accepts any schema, validates the chosen part of the request, and either rejects with a 400 or replaces the request data with the parsed (and coerced) result before calling next().

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

type Source = "body" | "query" | "params";

export function validate(schema: ZodType, source: Source = "body") {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req[source]);

    if (!result.success) {
      const errors = result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      }));
      return res.status(400).json({ error: "Validation failed", details: errors });
    }

    req[source] = result.data;
    next();
  };
}

Mounting it on a route keeps the handler clean. Inside the handler you can cast req.body to the inferred type, knowing the data already passed validation.

import express from "express";
import { createUserSchema, CreateUserInput } from "./schemas";
import { validate } from "./middleware";

const router = express.Router();

router.post("/users", validate(createUserSchema), async (req, res) => {
  const input = req.body as CreateUserInput;
  const user = await db.users.create(input);
  res.status(201).json(user);
});

export default router;

In Express 5, async handlers that throw are automatically forwarded to the error middleware, so you no longer need express-async-errors or manual try/catch wrappers around awaited calls.

Structured error responses

The middleware turns a ZodError into a flat, predictable JSON array. A request with an invalid email and a too-young age produces a clear, machine-readable response.

curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "A", "email": "not-an-email", "age": 15}'

Output:

{
  "error": "Validation failed",
  "details": [
    { "field": "name", "message": "Name must be at least 2 characters" },
    { "field": "email", "message": "A valid email is required" },
    { "field": "age", "message": "Must be 18 or older" }
  ]
}

Coercion and query strings

Query and path parameters always arrive as strings, so numeric or boolean fields need coercion. Zod’s z.coerce helpers convert input before validating, which is ideal for pagination parameters.

export const listQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  active: z.coerce.boolean().optional(),
});

router.get("/users", validate(listQuerySchema, "query"), async (req, res) => {
  const { page, limit } = req.query as z.infer<typeof listQuerySchema>;
  const users = await db.users.list({ page, limit });
  res.json(users);
});

Zod vs. other validators

FeatureZodJoiexpress-validator
Type inferenceBuilt-in via z.inferNoneNone
TypeScript-firstYesPartialPartial
DependenciesZeroA fewA few
Validation styleSchema objectSchema objectChained per-field
Coercionz.coerce.*convert option.toInt() etc.

Best practices

  • Keep schemas in a dedicated module and export both the schema and its z.infer type so handlers stay in sync with validation.
  • Prefer safeParse in middleware so you control the HTTP response instead of catching thrown ZodErrors.
  • Use z.coerce for query and path parameters, which are always strings on the wire.
  • Reassign req[source] = result.data so downstream code uses the parsed values, including defaults and coerced types.
  • Map error.issues to a stable shape (field + message) so clients can render errors predictably.
  • Add .strict() to object schemas when you want to reject unknown keys rather than silently dropping them.
  • Compose and reuse schemas with .extend(), .pick(), and .partial() instead of duplicating field definitions.
Last updated June 14, 2026
Was this helpful?