Zod Schemas
Type-safe validation that's a joy to use - define once, validate everywhere.
Why Zod?#
Validation is one of those things you always need but rarely want to write. Manual validation is tedious, error-prone, and hard to maintain:
// Manual validation - verbose and error-prone
if (!data.email) throw new Error('Email required');
if (typeof data.email !== 'string') throw new Error('Email must be string');
if (!data.email.includes('@')) throw new Error('Invalid email');
if (!data.age) throw new Error('Age required');
if (typeof data.age !== 'number') throw new Error('Age must be number');
if (data.age < 0 || data.age > 150) throw new Error('Invalid age');
// ... and so on for every field
This approach has problems:
- Repetitive boilerplate for every field
- Easy to forget edge cases
- Error messages scattered throughout code
- No type safety - TypeScript doesn't know what
datacontains after validation
Zod solves all of this. You describe what valid data looks like, and Zod handles validation, type inference, and error messages:
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
age: z.number().min(0).max(150),
});
const result = schema.parse(data); // Throws if invalid, returns typed data if valid
// TypeScript knows: result is { email: string; age: number }
Clean, readable, type-safe, and self-documenting.
Getting Started#
npm install zod
import { z } from 'zod';
// Define a schema - this is your source of truth
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().positive(),
});
// Validate data
const user = userSchema.parse({
name: 'John',
email: 'john@example.com',
age: 30,
});
// Returns the validated data with full TypeScript types
// Invalid data throws ZodError
userSchema.parse({ name: 'John', email: 'invalid', age: -5 });
// ZodError: [
// { path: ['email'], message: 'Invalid email' },
// { path: ['age'], message: 'Number must be greater than 0' }
// ]
Basic Types#
Zod has validators for all JavaScript primitives:
// Primitives
z.string() // Strings
z.number() // Numbers (includes floats)
z.boolean() // true or false
z.date() // Date objects
z.undefined() // undefined only
z.null() // null only
// Literals - exact values only
z.literal('active') // Only the string 'active'
z.literal(42) // Only the number 42
z.literal(true) // Only true, not false
// Catch-alls (use sparingly)
z.any() // Disables type checking
z.unknown() // Like any, but safer
When to use literals: When you need to match exact values, like status fields or discriminated unions.
String Validations#
Strings are the most common input type. Zod provides many built-in validators:
const stringSchema = z.string()
.min(1, 'Required') // Minimum length - use for "required" fields
.max(100, 'Too long') // Maximum length - protect against abuse
.email('Invalid email') // Email format
.url('Invalid URL') // URL format
.uuid('Invalid UUID') // UUID format
.regex(/^[A-Z]+$/, 'Must be uppercase') // Custom pattern
.trim() // Trim whitespace (transforms the value)
.toLowerCase() // Convert to lowercase (transforms the value)
.toUpperCase(); // Convert to uppercase (transforms the value)
Built-in format validators:
z.string().email() // Email addresses
z.string().url() // URLs (http/https)
z.string().uuid() // UUIDs
z.string().cuid() // CUIDs
z.string().datetime() // ISO datetime strings
z.string().ip() // IP addresses (v4 and v6)
Note: .trim(), .toLowerCase(), and .toUpperCase() are transformations - they modify the data, not just validate it. The output will have the transformation applied.
Number Validations#
const numberSchema = z.number()
.positive() // > 0
.negative() // < 0
.nonnegative() // >= 0 (allows zero)
.nonpositive() // <= 0 (allows zero)
.int() // Must be integer (no decimals)
.min(0) // >= 0
.max(100) // <= 100
.multipleOf(5) // Must be multiple of 5
.finite(); // Not Infinity or -Infinity
Common gotcha: Form inputs are always strings. Use z.coerce.number() to parse them:
// From a form: { age: "25" }
z.number().parse("25") // ERROR: Expected number, received string
z.coerce.number().parse("25") // OK: Returns 25 (number)
Objects#
Objects are where Zod shines. Define complex nested structures declaratively:
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().positive().optional(), // Optional field
role: z.enum(['user', 'admin']),
});
// TypeScript type is inferred automatically
type User = z.infer<typeof userSchema>;
// { id: string; name: string; email: string; age?: number; role: 'user' | 'admin' }
Optional and Nullable#
These are different concepts:
z.string().optional() // string | undefined - field can be missing
z.string().nullable() // string | null - field exists but can be null
z.string().nullish() // string | null | undefined - both
// With defaults - provide a value when missing
z.string().default('guest') // If undefined, becomes 'guest'
z.number().default(0) // If undefined, becomes 0
When to use which:
optional()- Form fields that aren't requirednullable()- Database fields that allow NULLdefault()- When you want a fallback value
Handling Unknown Keys#
By default, Zod strips keys that aren't in your schema:
const schema = z.object({ name: z.string() });
schema.parse({ name: 'John', extra: 'ignored' });
// Returns { name: 'John' } - 'extra' is removed
// Strict: error on unknown keys (good for APIs)
const strict = z.object({ name: z.string() }).strict();
strict.parse({ name: 'John', extra: 'ignored' });
// ZodError: Unrecognized key 'extra'
// Passthrough: keep unknown keys (rare)
const pass = z.object({ name: z.string() }).passthrough();
pass.parse({ name: 'John', extra: 'kept' });
// Returns { name: 'John', extra: 'kept' }
Recommendation: Use .strict() for API input validation to catch typos and unexpected fields.
Arrays#
z.array(z.string()) // string[]
z.array(z.number()).min(1) // At least 1 item
z.array(z.number()).max(10) // At most 10 items
z.array(z.number()).length(5) // Exactly 5 items
z.array(z.number()).nonempty() // At least 1 item (type-safe)
// Array of objects
z.array(z.object({
id: z.string(),
quantity: z.number().positive(),
}))
Use .nonempty() over .min(1) when you need TypeScript to know the array has at least one element.
Enums#
For when a field can only be one of specific values:
// Zod enum
const roleSchema = z.enum(['user', 'admin', 'moderator']);
type Role = z.infer<typeof roleSchema>; // 'user' | 'admin' | 'moderator'
// Get the allowed values (useful for dropdowns, documentation)
roleSchema.options; // ['user', 'admin', 'moderator']
// Native TypeScript enums also work
enum Status {
Active = 'active',
Inactive = 'inactive',
}
const statusSchema = z.nativeEnum(Status);
Unions and Discriminated Unions#
Simple Union#
When a value can be one of several types:
const stringOrNumber = z.union([z.string(), z.number()]);
// or shorthand
const stringOrNumber = z.string().or(z.number());
Discriminated Union#
When you have objects that share a "type" field - this is common for events, API responses, etc.:
const eventSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('login'),
userId: z.string(),
ip: z.string(),
}),
z.object({
type: z.literal('purchase'),
userId: z.string(),
amount: z.number(),
productId: z.string(),
}),
z.object({
type: z.literal('logout'),
userId: z.string(),
}),
]);
// Zod checks the 'type' field first, then validates the rest
// This is more efficient than regular union and gives better errors
Why discriminated unions? They're faster (Zod can check the type field first) and give better error messages (tells you which variant failed).
Transformations#
Transform data while validating. The output type can be different from the input:
const userSchema = z.object({
email: z.string()
.email()
.toLowerCase() // Normalize to lowercase
.trim(), // Remove whitespace
name: z.string()
.trim()
.transform(s => s.split(' ').map(w =>
w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
).join(' ')), // Convert to Title Case
birthDate: z.string()
.transform(s => new Date(s)), // String → Date object
});
// Input: { email: ' JOHN@EXAMPLE.COM ', name: 'john doe', birthDate: '1990-01-15' }
// Output: { email: 'john@example.com', name: 'John Doe', birthDate: Date }
Use cases for transforms:
- Normalizing data (lowercase emails, trim whitespace)
- Converting types (string dates to Date objects)
- Computed fields
Refinements#
When built-in validators aren't enough, add custom validation logic:
// Single refinement
const passwordSchema = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val),
{ message: 'Must contain uppercase letter' }
)
.refine(
(val) => /[0-9]/.test(val),
{ message: 'Must contain number' }
);
// Object-level refinement (when you need to compare fields)
const formSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords do not match',
path: ['confirmPassword'], // Which field shows the error
}
);
When to use refinements:
- Custom validation logic
- Cross-field validation (password confirmation, date ranges)
- Business rules that can't be expressed with built-in validators
Real-World Schemas#
User Registration#
// src/validators/auth.js
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string()
.min(1, 'Email is required')
.email('Invalid email format')
.toLowerCase()
.trim(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password too long')
.refine(
(val) => /[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val),
'Password must contain uppercase, lowercase, and number'
),
name: z.string()
.min(1, 'Name is required')
.max(100, 'Name too long')
.trim(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
});
export type RegisterInput = z.infer<typeof registerSchema>;
Create Order#
// src/validators/orders.js
const orderItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
});
export const createOrderSchema = z.object({
items: z.array(orderItemSchema)
.min(1, 'Order must have at least one item')
.max(50, 'Maximum 50 items per order'),
shippingAddress: z.object({
street: z.string().min(1).max(200),
city: z.string().min(1).max(100),
state: z.string().length(2).toUpperCase(),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().length(2).toUpperCase().default('US'),
}),
notes: z.string().max(500).optional(),
paymentMethod: z.enum(['card', 'paypal', 'bank_transfer']),
});
Query Parameters#
Query parameters from URLs are always strings. Use z.coerce to parse them:
// src/validators/common.js
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
sort: z.string().optional(),
});
// Input: ?page=2&limit=50
// Output: { page: 2, limit: 50, sort: undefined }
export const searchSchema = paginationSchema.extend({
q: z.string().min(1).max(100).optional(),
status: z.enum(['active', 'inactive', 'all']).default('all'),
});
Validation Middleware#
Integrate Zod with Express using middleware:
// src/middleware/validate.js
import { z } from 'zod';
import { ValidationError } from '../utils/errors.js';
export function validate(schema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
const details = error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}));
throw new ValidationError('Validation failed', details);
}
throw error;
}
};
}
export function validateQuery(schema) {
return (req, res, next) => {
try {
req.query = schema.parse(req.query);
next();
} catch (error) {
if (error instanceof z.ZodError) {
const details = error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}));
throw new ValidationError('Invalid query parameters', details);
}
throw error;
}
};
}
Usage in routes:
// src/routes/users.js
import { validate, validateQuery } from '../middleware/validate.js';
import { createUserSchema } from '../validators/users.js';
import { paginationSchema } from '../validators/common.js';
router.get('/', validateQuery(paginationSchema), userController.list);
router.post('/', validate(createUserSchema), userController.create);
Safe Parsing#
.parse() throws on invalid data. If you want to handle errors without exceptions, use .safeParse():
const result = schema.safeParse(data);
if (result.success) {
console.log(result.data); // Validated data
} else {
console.log(result.error.issues); // Array of validation errors
}
When to use safeParse:
- When you want to collect all errors without throwing
- When validation failure is expected (user input)
- When you need to log/report errors before responding
Reusing Schemas#
Build complex schemas from simple ones:
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(['user', 'admin']),
createdAt: z.date(),
});
// All fields optional (for PATCH updates)
const updateUserSchema = userSchema.partial();
// { id?: string; email?: string; name?: string; ... }
// Only specific fields optional
const patchUserSchema = userSchema.partial({
email: true,
name: true,
});
// Pick specific fields (for create - exclude id, createdAt)
const createUserSchema = userSchema.pick({
email: true,
name: true,
role: true,
});
// { email: string; name: string; role: 'user' | 'admin' }
// Omit specific fields
const publicUserSchema = userSchema.omit({
role: true,
});
// Everything except role
Why this matters: Define your core schema once, derive variations for different operations. Changes to the base schema automatically propagate.
Key Takeaways#
-
Zod is declarative - Describe what valid data looks like, not how to check it.
-
Type inference is automatic - Use
z.infer<typeof schema>to get TypeScript types. -
Schemas are composable - Build complex schemas from simple ones with
.extend(),.pick(),.omit(),.partial(). -
Transform while validating - Clean and convert data in one step.
-
Use middleware - Validate at the route level, before your controller code runs.
-
SafeParse for user input - When you expect validation to fail, don't throw exceptions.
-
One source of truth - Your schemas define validation, types, and documentation together.
The Pattern
Define schemas once, use them for API validation, type inference, and documentation. Zod becomes your single source of truth for what valid data looks like.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.