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.inferreflects 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. Usez.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-errorsor manualtry/catchwrappers 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
| Feature | Zod | Joi | express-validator |
|---|---|---|---|
| Type inference | Built-in via z.infer | None | None |
| TypeScript-first | Yes | Partial | Partial |
| Dependencies | Zero | A few | A few |
| Validation style | Schema object | Schema object | Chained per-field |
| Coercion | z.coerce.* | convert option | .toInt() etc. |
Best practices
- Keep schemas in a dedicated module and export both the schema and its
z.infertype so handlers stay in sync with validation. - Prefer
safeParsein middleware so you control the HTTP response instead of catching thrownZodErrors. - Use
z.coercefor query and path parameters, which are always strings on the wire. - Reassign
req[source] = result.dataso downstream code uses the parsed values, including defaults and coerced types. - Map
error.issuesto 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.