Zod Validation with TypeScript — Complete Guide 2026
Advertisement
Introduction
Why Zod Over Manual Validation
TypeScript types are erased at runtime. A route handler typed as (req: Request) has no guarantee that req.body matches the type — it is any at runtime. Zod provides TypeScript-first schema validation that generates static types and validates at runtime simultaneously.
Zod validates in under 0.1ms per call and ships a 2 KB core bundle with zero dependencies.
Core Primitives
import { z } from 'zod';
// Primitives
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const literalSchema = z.literal('admin');
const enumSchema = z.enum(['user', 'admin', 'moderator']);
// Type extraction — TypeScript type matches validation
type Role = z.infer<typeof enumSchema>; // 'user' | 'admin' | 'moderator'Object Schemas
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(2).max(100),
email: z.string().email().toLowerCase(),
age: z.number().int().min(13).max(150).optional(),
role: z.enum(['user', 'admin']).default('user'),
tags: z.array(z.string().max(50)).max(10).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
});
type User = z.infer<typeof UserSchema>;
// Parse — throws ZodError on failure
const user = UserSchema.parse(req.body);
// SafeParse — returns result object (no throw)
const result = UserSchema.safeParse(req.body);
if (!result.success) {
console.error(result.error.flatten());
} else {
const user = result.data; // fully typed
}Advanced Schema Patterns
// Nested objects
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5}$/, 'Invalid ZIP code'),
});
const ProfileSchema = z.object({
user: UserSchema,
address: AddressSchema,
social: z.object({
twitter: z.string().startsWith('@').optional(),
github: z.string().url().optional(),
}),
});
// Unions
const ResultSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.unknown() }),
z.object({ status: z.literal('error'), message: z.string() }),
]);
// Transform + refine
const PasswordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] }
);
// Custom validator
const SlugSchema = z.string().refine(
(s) => /^[a-z0-9-]+$/.test(s),
{ message: 'Slug must contain only lowercase letters, numbers, and hyphens' }
);
// Transform — coerce strings to numbers from query params
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});Partial, Pick, and Omit
// Partial — all fields optional (for PATCH endpoints)
const UpdateUserSchema = UserSchema.partial().required({ id: true });
// Pick — select specific fields
const CreateUserSchema = UserSchema.pick({ name: true, email: true, role: true });
// Omit — exclude fields (e.g., server-generated fields)
const UserResponseSchema = UserSchema.omit({ metadata: true });
// Extend — add fields to existing schema
const AdminUserSchema = UserSchema.extend({
permissions: z.array(z.string()),
lastLoginAt: z.date(),
});Express Validation Middleware
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
interface ValidationSchemas {
body?: AnyZodObject;
query?: AnyZodObject;
params?: AnyZodObject;
}
export function validate(schemas: ValidationSchemas) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
if (schemas.body) req.body = await schemas.body.parseAsync(req.body);
if (schemas.query) req.query = await schemas.query.parseAsync(req.query) as any;
if (schemas.params) req.params = await schemas.params.parseAsync(req.params) as any;
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(422).json({
error: 'Validation failed',
issues: err.flatten().fieldErrors,
});
}
next(err);
}
};
}
// Usage
const createUserSchemas: ValidationSchemas = {
body: z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
}),
};
const listUsersSchemas: ValidationSchemas = {
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
role: z.enum(['user', 'admin']).optional(),
}),
};
router.post('/users', validate(createUserSchemas), createUser);
router.get('/users', validate(listUsersSchemas), listUsers);Async Validation
// Validate uniqueness in the database
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await db.users.count({ where: { email } });
return exists === 0;
},
{ message: 'Email already in use' }
);
// Use parseAsync for async refinements
const result = await UniqueEmailSchema.safeParseAsync(req.body.email);Error Formatting
import { ZodError } from 'zod';
function formatZodError(error: ZodError) {
return {
errors: error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
};
}
// Flat field errors map
error.flatten().fieldErrors;
// { email: ['Invalid email'], name: ['String must contain at least 2 character(s)'] }
// Form errors (root-level)
error.flatten().formErrors;Common Mistakes
- Using
.parse()instead of.safeParse()in route handlers — uncaught ZodErrors bypass error middleware - Not coercing query parameter types — query params arrive as strings; use
z.coerce.number() - Putting async refinements in schemas used with
.parse()— use.parseAsync()for async validators - Over-validating immutable server fields (id, createdAt) in create schemas — omit them
- Returning raw Zod error objects to clients — format them into consistent API error shapes
Best Practices
- Use
z.inferto derive TypeScript types from Zod schemas — one source of truth for type and validation - Use
.safeParse()in request handlers so you control the response; use.parse()only in trusted contexts - Create a shared
schemas/directory and import schemas in both routes and OpenAPI documentation - Coerce query and URL param types with
z.coerce— they are always strings in Express - Add
.describe('...')to schema fields to improve generated OpenAPI documentation
Key Takeaways
- TypeScript types vanish at runtime — Zod bridges the gap with runtime validation that generates types
z.infer<typeof Schema>gives a TypeScript type derived from the Zod schema — no duplication.safeParse()returns{ success, data, error }without throwing — preferred in route handlersz.coerce.number()auto-converts string query params to numbers — essential for Expressrefine()adds custom validation logic;transform()mutates the parsed value- Async refinements (database uniqueness checks) require
.parseAsync()or.safeParseAsync() - Validation middleware with Zod removes boilerplate from individual route handlers
- Use Zod schemas as the single source of truth for both Express validation and OpenAPI documentation
Advertisement
Related reading
Zod v4 — What Changed and Why It Matters for Backend Validation8 min readType-Safe Environment Variables in 2026 — T3 Env, Zod, and Runtime Validation8 min readNode.js API Best Practices 2026 — Build Production-Ready REST APIs5 min readPrisma ORM Guide 2026 — Type-Safe Database Access with PostgreSQL5 min readAPI-First Development in 2026 — Design, Mock, Validate, Then Build6 min readbetter-auth — The Open-Source Auth Library That Replaces NextAuth6 min read