Zod Validation with TypeScript — Complete Guide 2026

Sanjeev SharmaSanjeev Sharma
5 min read

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.infer to 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 handlers
  • z.coerce.number() auto-converts string query params to numbers — essential for Express
  • refine() 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading