Zod Validation Skill
Summary
TypeScript-first schema validation library with static type inference. Define schemas once, get runtime validation and compile-time types automatically.
When to Use
- Form validation with type-safe data
- API request/response validation
- Environment variable validation
- Runtime type checking with TypeScript inference
- tRPC procedure inputs/outputs
- Database schema validation (Drizzle, Prisma)
Quick Start
import { z } from 'zod';
// Define schema
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().min(18),
role: z.enum(['user', 'admin'])
});
// Infer TypeScript type
type User = z.infer<typeof UserSchema>;
// Validate data
const result = UserSchema.safeParse(data);
if (result.success) {
const user: User = result.data;
}
<!-- SECTION: primitives -->
Primitive Types
Basic Types
import { z } from 'zod';
// String with validation
const nameSchema = z.string()
.min(2, "Too short")
.max(50, "Too long")
.trim();
const emailSchema = z.string().email();
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const regexSchema = z.string().regex(/^[A-Z]{3}$/);
// Numbers
const ageSchema = z.number()
.int("Must be integer")
.positive()
.min(0)
.max(120);
const priceSchema = z.number()
.positive()
.multipleOf(0.01); // Currency precision
// Boolean
const isActiveSchema = z.boolean();
// Date
const createdAtSchema = z.date()
.min(new Date('2020-01-01'))
.max(new Date());
const dateStringSchema = z.string().datetime(); // ISO 8601
const dateOnlySchema = z.string().date(); // YYYY-MM-DD
Special Types
// Literal values
const roleSchema = z.literal('admin');
const statusSchema = z.literal('pending');
// Enums
const ColorEnum = z.enum(['red', 'green', 'blue']);
type Color = z.infer<typeof ColorEnum>; // 'red' | 'green' | 'blue'
const NativeEnum = z.nativeEnum(MyEnum); // For TypeScript enums
// Nullable and Optional
const optionalString = z.string().optional(); // string | undefined
const nullableString = z.string().nullable(); // string | null
const nullishString = z.string().nullish(); // string | null | undefined
// Default values
const countSchema = z.number().default(0);
const settingsSchema = z.object({
theme: z.string().default('light'),
notifications: z.boolean().default(true)
});
<!-- SECTION: objects_and_arrays -->
Objects and Arrays
Object Schemas
// Basic object
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
age: z.number().optional()
});
// Nested objects
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string(),
zipCode: z.string()
});
const PersonSchema = z.object({
name: z.string(),
address: AddressSchema,
contacts: z.object({
email: z.string().email(),
phone: z.string().optional()
})
});
// Strict vs Passthrough
const strictSchema = z.object({ name: z.string() }).strict();
// Rejects unknown keys
const passthroughSchema = z.object({ name: z.string() }).passthrough();
// Allows unknown keys
const stripSchema = z.object({ name: z.string() }).strip();
// Removes unknown keys (default)
Array Schemas
// Simple arrays
const stringArray = z.array(z.string());
const numberArray = z.array(z.number()).min(1).max(10);
// Array of objects
const UsersSchema = z.array(UserSchema);
// Non-empty arrays
const tagSchema = z.array(z.string()).nonempty("At least one tag required");
// Fixed-length arrays (tuples)
const coordinateSchema = z.tuple([z.number(), z.number()]);
type Coordinate = z.infer<typeof coordinateSchema>; // [number, number]
// Tuple with rest
const csvRowSchema = z.tuple([z.string(), z.number()]).rest(z.string());
// [string, number, ...string[]]
Records and Maps
// Record (object with dynamic keys)
const userRolesSchema = z.record(
z.string(), // key type
z.enum(['admin', 'user', 'guest']) // value type
);
type UserRoles = z.infer<typeof userRolesSchema>;
// { [key: string]: 'admin' | 'user' | 'guest' }
// Map
const configMapSchema = z.map(
z.string(), // key
z.number() // value
);
// Set
const uniqueTagsSchema = z.set(z.string());
<!-- SECTION: type_inference -->
Type Inference
import { z } from 'zod';
// Infer output type
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
age: z.number()
});
type User = z.infer<typeof UserSchema>;
// { id: string; email: string; age: number }
// Infer input type (before transforms)
const TransformSchema = z.object({
date: z.string().transform(s => new Date(s))
});
type Input = z.input<typeof TransformSchema>;
// { date: string }
type Output = z.output<typeof TransformSchema>;
// { date: Date }
// Using inferred types in functions
function createUser(data: User): void {
// data is type-safe
}
function validateAndCreate(data: unknown): User | null {
const result = UserSchema.safeParse(data);
return result.success ? result.data : null;
}
<!-- SECTION: validation_methods -->
Validation Methods
Parse vs SafeParse
// parse() - Throws on failure
try {
const user = UserSchema.parse(data);
// user is type User
} catch (error) {
if (error instanceof z.ZodError) {
console.error(error.issues);
}
}
// safeParse() - Returns result object
const result = UserSchema.safeParse(data);
if (result.success) {
const user = result.data; // type User
} else {
const errors = result.error.issues;
errors.forEach(err => {
console.log(`${err.path}: ${err.message}`);
});
}
// parseAsync() - For async refinements
const asyncResult = await UserSchema.parseAsync(data);
// safeParseAsync() - Safe async version
const asyncSafeResult = await UserSchema.safeParseAsync(data);
Partial Validation
// Check if data matches schema without throwing
const isValid = UserSchema.safeParse(data).success;
// Custom type guards
function isUser(data: unknown): data is User {
return UserSchema.safeParse(data).success;
}
if (isUser(unknownData)) {
// TypeScript knows unknownData is User
console.log(unknownData.email);
}
<!-- SECTION: schema_composition -->
Schema Composition
Extending and Merging
// Extend (add fields)
const BaseUserSchema = z.object({
id: z.string(),
email: z.string()
});
const AdminUserSchema = BaseUserSchema.extend({
role: z.literal('admin'),
permissions: z.array(z.string())
});
// Merge (combine schemas)
const NameSchema = z.object({ name: z.string() });
const AgeSchema = z.object({ age: z.number() });
const PersonSchema = NameSchema.merge(AgeSchema);
// { name: string; age: number }
// Pick (select fields)
const UserIdEmail = UserSchema.pick({ id: true, email: true });
// Omit (exclude fields)
const UserWithoutId = UserSchema.omit({ id: true });
// Partial (make all fields optional)
const PartialUser = UserSchema.partial();
// DeepPartial (recursive partial)
const DeepPartialUser = UserSchema.deepPartial();
// Required (make all fields required)
const RequiredUser = UserSchema.required();
Union and Intersection
// Union (OR)
const StringOrNumber = z.union([z.string(), z.number()]);
// Shorthand
const StringOrNumberAlt = z.string().or(z.number());
// Discriminated Union (tagged union)
const SuccessResponse = z.object({
status: z.literal('success'),
data: z.any()
});
const ErrorResponse = z.object({
status: z.literal('error'),
message: z.string()
});
const ApiResponse = z.discriminatedUnion('status', [
SuccessResponse,
ErrorResponse
]);
// Intersection (AND)
const User = z.object({ name: z.string() });
const Timestamps = z.object({
createdAt: z.date(),
updatedAt: z.date()
});
const UserWithTimestamps = z.intersection(User, Timestamps);
// Shorthand
const UserWithTimestampsAlt = User.and(Timestamps);
<!-- SECTION: transformations -->
Transformations and Refinements
Transform
// Transform data after validation
const StringToNumber = z.string().transform(val => parseInt(val, 10));
const DateSchema = z.string().transform(str => new Date(str));
// Chaining transforms
const TrimmedLowercase = z.string()
.transform(s => s.trim())
.transform(s => s.toLowerCase());
// Transform with validation
const PositiveStringNumber = z.string()
.transform(val => parseInt(val, 10))
.refine(n => n > 0, "Must be positive");
// Complex transformations
const UserInputSchema = z.object({
name: z.string().transform(s => s.trim()),
email: z.string().email().transform(s => s.toLowerCase()),
birthDate: z.string().transform(s => new Date(s)),
tags: z.string().transform(s => s.split(',').map(t => t.trim()))
});
type UserInput = z.input<typeof UserInputSchema>;
// { name: string; email: string; birthDate: string; tags: string }
type User = z.output<typeof UserInputSchema>;
// { name: string; email: string; birthDate: Date; tags: string[] }
Refine (Custom Validation)
// Simple refinement
const PasswordSchema = z.string()
.min(8)
.refine(
val => /[A-Z]/.test(val),
"Must contain uppercase letter"
)
.refine(
val => /[0-9]/.test(val),
"Must contain number"
);
// Refinement with custom error
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email);
return !exists;
},
{ message: "Email already taken" }
);
// Object-level refinement
const PasswordMatchSchema = z.object({
password: z.string(),
confirmPassword: z.string()
}).refine(
data => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"] // Error location
}
);
// Multiple field validation
const DateRangeSchema = z.object({
startDate: z.date(),
endDate: z.date()
}).refine(
data => data.endDate > data.startDate,
{
message: "End date must be after start date",
path: ["endDate"]
}
);
SuperRefine (Advanced)
// Access to Zod context for complex validation
const ComplexSchema = z.object({
type: z.enum(['email', 'phone']),
value: z.string()
}).superRefine((data, ctx) => {
if (data.type === 'email') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(data.value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid email format",
path: ["value"]
});
}
} else if (data.type === 'phone') {
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
if (!phoneRegex.test(data.value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid phone format",
path: ["value"]
});
}
}
});
// Multiple issues
const RegistrationSchema = z.object({
username: z.string(),
email: z.string(),
age: z.number()
}).superRefine(async (data, ctx) => {
// Check username availability
if (await usernameTaken(data.username)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Username taken",
path: ["username"]
});
}
// Check email availability
if (await emailTaken(data.email)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email already registered",
path: ["email"]
});
}
// Age restriction
if (data.age < 18) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must be 18 or older",
path: ["age"]
});
}
});
<!-- SECTION: error_handling -->
Error Handling
Custom Error Messages
// Field-level messages
const UserSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
age: z.number({
required_error: "Age is required",
invalid_type_error: "Age must be a number"
}).min(18, { message: "Must be 18 or older" })
});
// Global error map
import { z } from 'zod';
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "This field must be text" };
}
}
if (issue.code === z.ZodIssueCode.too_small) {
if (issue.type === "string") {
return { message: `Minimum ${issue.minimum} characters required` };
}
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
Processing Errors
// Flatten errors for forms
const result = UserSchema.safeParse(data);
if (!result.success) {
const flatErrors = result.error.flatten();
console.log(flatErrors.formErrors); // Top-level errors
console.log(flatErrors.fieldErrors);
// { email: ["Invalid email"], age: ["Must be 18+"] }
}
// Format for API response
function formatZodError(error: z.ZodError) {
return error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message
}));
}
// Example usage
const result = UserSchema.safeParse(data);
if (!result.success) {
return res.status(400).json({
errors: formatZodError(result.error)
});
}
<!-- SECTION: async_validation -->
Async Validation
import { z } from 'zod';
// Async refinement
const UsernameSchema = z.string().refine(
async (username) => {
const available = await checkUsernameAvailable(username);
return available;
},
{ message: "Username already taken" }
);
// Must use parseAsync or safeParseAsync
const result = await UsernameSchema.safeParseAsync("john_doe");
// Complex async validation
const RegistrationSchema = z.object({
username: z.string().refine(
async (val) => !(await usernameTaken(val)),
"Username taken"
),
email: z.string().email().refine(
async (val) => !(await emailTaken(val)),
"Email already registered"
),
inviteCode: z.string().refine(
async (code) => await validateInviteCode(code),
"Invalid invite code"
)
});
// Validate
const userData = await RegistrationSchema.parseAsync(input);
// With error handling
const result = await RegistrationSchema.safeParseAsync(input);
if (!result.success) {
// Handle validation errors
}
<!-- SECTION: advanced_types -->
Advanced Types
Recursive Types
// Self-referential schemas
type Category = {
name: string;
subcategories: Category[];
};
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema)
})
);
// Tree structure
type TreeNode = {
value: number;
left?: TreeNode;
right?: TreeNode;
};
const TreeNodeSchema: z.ZodType<TreeNode> = z.lazy(() =>
z.object({
value: z.number(),
left: TreeNodeSchema.optional(),
right: TreeNodeSchema.optional()
})
);
Discriminated Unions
// Type-safe union based on discriminator field
const Circle = z.object({
kind: z.literal('circle'),
radius: z.number()
});
const Rectangle = z.object({
kind: z.literal('rectangle'),
width: z.number(),
height: z.number()
});
const Triangle = z.object({
kind: z.literal('triangle'),
base: z.number(),
height: z.number()
});
const Shape = z.discriminatedUnion('kind', [
Circle,
Rectangle,
Triangle
]);
type Shape = z.infer<typeof Shape>;
// TypeScript can narrow based on discriminator
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}
Preprocess
// Transform before validation
const NumberFromString = z.preprocess(
(val) => (typeof val === 'string' ? parseInt(val, 10) : val),
z.number()
);
// Clean data before validation
const TrimmedString = z.preprocess(
(val) => (typeof val === 'string' ? val.trim() : val),
z.string()
);
// Parse JSON strings
const JsonSchema = z.preprocess(
(val) => (typeof val === 'string' ? JSON.parse(val) : val),
z.object({
name: z.string(),
age: z.number()
})
);
// Form data preprocessing
const FormDataSchema = z.preprocess(
(data) => {
// Convert FormData to object
if (data instanceof FormData) {
return Object.fromEntries(data.entries());
}
return data;
},
z.object({
name: z.string(),
email: z.string().email()
})
);
Branded Types
// Create nominal types
const UserId = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof UserId>;
const Email = z.string().email().brand<'Email'>();
type Email = z.infer<typeof Email>;
// Prevents mixing similar types
function getUserById(id: UserId) { /* ... */ }
function sendEmail(to: Email) { /* ... */ }
const userId = UserId.parse('123e4567-e89b-12d3-a456-426614174000');
const email = Email.parse('user@example.com');
getUserById(userId); // ✓
getUserById(email); // ✗ Type error
<!-- SECTION: api_handler_patterns -->
API Handler Patterns
Generic Validated Handler
Create type-safe API handlers with automatic validation, error formatting, and authentication.
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
// Generic handler type with validated input
type ValidatedHandler<T, C = T> = (
state: {
input: T;
request: NextRequest;
ctx: RouteContext<C>;
}
) => Promise<Response> | Response;
// Route context interface
interface RouteContext<T = unknown> {
params: T;
}
// Validation configuration
interface ValidationConfig<T extends z.ZodType, C extends z.ZodType> {
input?: {
schema: T;
source: 'body' | 'query' | 'params';
};
auth?: {
role: 'admin' | 'provider' | 'user';
};
context?: C;
}
// Generic validated handler wrapper
export function validatedHandler<
T extends z.ZodType,
C extends z.ZodType = z.ZodType<any>
>(
config: ValidationConfig<T, C>,
handler: ValidatedHandler<z.infer<T>, z.infer<C>>
) {
return async (
request: NextRequest,
ctx: RouteContext<z.infer<C>>
): Promise<Response> => {
try {
// Extract input based on source
let rawInput: unknown;
if (config.input) {
switch (config.input.source) {
case 'body':
rawInput = await request.json();
break;
case 'query':
rawInput = Object.fromEntries(request.nextUrl.searchParams);
break;
case 'params':
rawInput = ctx.params;
break;
}
// Validate input
const result = config.input.schema.safeParse(rawInput);
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.issues.map(err => ({
path: err.path.join('.'),
message: err.message,
code: err.code,
})),
},
{ status: 400 }
);
}
// Call handler with validated input
return handler({
input: result.data,
request,
ctx
});
}
// No validation required
return handler({
input: {} as z.infer<T>,
request,
ctx
});
} catch (error) {
console.error('Handler error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
};
}
Usage Example:
// Define validation schema
const CreateCampSchema = z.object({
name: z.string().min(3),
location: z.string(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
capacity: z.number().positive(),
});
// Type-safe route handler
export const POST = validatedHandler(
{
input: {
schema: CreateCampSchema,
source: 'body',
},
auth: { role: 'admin' },
},
async ({ input, request }) => {
// input is fully typed as z.infer<typeof CreateCampSchema>
const camp = await createCamp({
name: input.name,
location: input.location,
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
capacity: input.capacity,
});
return NextResponse.json(camp, { status: 201 });
}
);
Discriminated Unions for Complex Types
Use discriminated unions to handle multiple input types with type-safe narrowing.
// Location selection with discriminated union
const LocationSelectionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('map-bounds'),
north: z.number().min(-90).max(90),
south: z.number().min(-90).max(90),
east: z.number().min(-180).max(180),
west: z.number().min(-180).max(180),
}),
z.object({
type: z.literal('address'),
address: z.string().min(1),
radius: z.number().positive().optional(),
}),
z.object({
type: z.literal('user-location'),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
radius: z.number().positive().default(10),
}),
]);
type LocationSelection = z.infer<typeof LocationSelectionSchema>;
// Type-safe handler with union narrowing
function processLocation(selection: LocationSelection) {
switch (selection.type) {
case 'map-bounds':
return searchByBounds({
north: selection.north,
south: selection.south,
east: selection.east,
west: selection.west,
});
case 'address':
return searchByAddress({
address: selection.address,
radius: selection.radius,
});
case 'user-location':
return searchByCoordinates({
lat: selection.lat,
lng: selection.lng,
radius: selection.radius,
});
}
}
Complete Type Mapping with Required
Ensure all keys are mapped with TypeScript's Required utility type.
import { NumberParam, StringParam, DateParam } from 'next-query-params';
// Filters interface
interface Filters {
startDate?: Date;
endDate?: Date;
status?: 'active' | 'inactive' | 'pending';
search?: string;
minPrice?: number;
maxPrice?: number;
}
// Query param config interface
interface QueryParamConfig<T> {
encode: (value: T) => string;
decode: (value: string | undefined) => T | undefined;
}
// Complete mapping enforced by Required<Filters>
const filtersQueryParamConfigMap: {
[Key in keyof Required<Filters>]: QueryParamConfig<Filters[Key]>;
} = {
startDate: DateParam,
endDate: DateParam,
status: StringParam,
search: StringParam,
minPrice: NumberParam,
maxPrice: NumberParam,
// TypeScript error if any key is missing
};
// Zod schema matching the interface
const FiltersSchema = z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
status: z.enum(['active', 'inactive', 'pending']).optional(),
search: z.string().optional(),
minPrice: z.number().positive().optional(),
maxPrice: z.number().positive().optional(),
});
// Validation with cross-field checks
const ValidatedFiltersSchema = FiltersSchema.refine(
(data) => {
if (data.startDate && data.endDate) {
return data.endDate >= data.startDate;
}
return true;
},
{
message: 'End date must be after start date',
path: ['endDate'],
}
).refine(
(data) => {
if (data.minPrice && data.maxPrice) {
return data.maxPrice >= data.minPrice;
}
return true;
},
{
message: 'Max price must be greater than min price',
path: ['maxPrice'],
}
);
Structured Error Response Format
Create consistent, type-safe error responses.
// Error response schema
const ApiErrorSchema = z.object({
code: z.enum([
'VALIDATION_ERROR',
'AUTHENTICATION_ERROR',
'AUTHORIZATION_ERROR',
'NOT_FOUND',
'INTERNAL_ERROR',
]),
message: z.string(),
details: z.array(
z.object({
path: z.string(),
message: z.string(),
code: z.string().optional(),
})
).optional(),
timestamp: z.string().datetime(),
});
type ApiError = z.infer<typeof ApiErrorSchema>;
// Format Zod errors for API response
function formatZodError(error: z.ZodError): ApiError {
return {
code: 'VALIDATION_ERROR',
message: 'Input validation failed',
details: error.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
timestamp: new Date().toISOString(),
};
}
// Error response helper
function errorResponse(
code: ApiError['code'],
message: string,
status: number,
details?: ApiError['details']
): Response {
const error: ApiError = {
code,
message,
details,
timestamp: new Date().toISOString(),
};
return NextResponse.json(error, { status });
}
// Usage in handler
export const POST = validatedHandler(
{
input: { schema: CreateUserSchema, source: 'body' },
},
async ({ input }) => {
try {
const user = await createUser(input);
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
formatZodError(error),
{ status: 400 }
);
}
return errorResponse(
'INTERNAL_ERROR',
'Failed to create user',
500
);
}
}
);
Query Parameter Transformation
Handle query parameter parsing with validation and transformation.
// Query param schema with transforms
const SearchParamsSchema = z.object({
// String to number with validation
page: z
.string()
.transform(Number)
.pipe(z.number().int().positive().default(1)),
// String to number with limits
pageSize: z
.string()
.transform(Number)
.pipe(z.number().int().min(1).max(100).default(20)),
// CSV string to array
tags: z
.string()
.optional()
.transform(val => val ? val.split(',').map(t => t.trim()) : []),
// ISO date string to Date
startDate: z
.string()
.datetime()
.optional()
.transform(val => val ? new Date(val) : undefined),
// Boolean string to boolean
includeInactive: z
.string()
.optional()
.transform(val => val === 'true')
.pipe(z.boolean().default(false)),
// Enum validation
sortBy: z
.enum(['name', 'date', 'price'])
.default('date'),
// Sort order
sortOrder: z
.enum(['asc', 'desc'])
.default('desc'),
});
// Handler with query param validation
export const GET = validatedHandler(
{
input: {
schema: SearchParamsSchema,
source: 'query',
},
},
async ({ input }) => {
// All params are properly typed and transformed
const results = await searchItems({
page: input.page, // number
pageSize: input.pageSize, // number
tags: input.tags, // string[]
startDate: input.startDate, // Date | undefined
includeInactive: input.includeInactive, // boolean
sortBy: input.sortBy, // 'name' | 'date' | 'price'
sortOrder: input.sortOrder, // 'asc' | 'desc'
});
return NextResponse.json(results);
}
);
Schema Composition for Reusable Validation
Build complex schemas from reusable parts.
// Base schemas
const TimestampSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const PaginationSchema = z.object({
page: z.number().int().positive(),
pageSize: z.number().int().min(1).max(100),
total: z.number().int().nonnegative(),
});
const LocationSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
address: z.string().optional(),
});
// Composed schemas
const CampSchema = z.object({
id: z.string().uuid(),
name: z.string().min(3),
location: LocationSchema,
capacity: z.number().positive(),
status: z.enum(['active', 'inactive', 'full']),
}).merge(TimestampSchema);
const CampListResponseSchema = z.object({
camps: z.array(CampSchema),
pagination: PaginationSchema,
});
// Type inference works across composition
type Camp = z.infer<typeof CampSchema>;
type CampListResponse = z.infer<typeof CampListResponseSchema>;
// Usage in handler
export const GET = validatedHandler(
{
input: {
schema: z.object({
page: z.number().default(1),
pageSize: z.number().default(20),
}),
source: 'query',
},
},
async ({ input }) => {
const camps = await getCamps(input.page, input.pageSize);
// Response validated against schema
const response: CampListResponse = {
camps,
pagination: {
page: input.page,
pageSize: input.pageSize,
total: await getCampCount(),
},
};
return NextResponse.json(response);
}
);
<!-- SECTION: integrations -->
Integrations
React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const FormSchema = z.object({
username: z.string().min(3, "Minimum 3 characters"),
email: z.string().email("Invalid email"),
age: z.number().min(18, "Must be 18+")
});
type FormData = z.infer<typeof FormSchema>;
function MyForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<FormData>({
resolver: zodResolver(FormSchema)
});
const onSubmit = (data: FormData) => {
// data is validated and typed
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
tRPC
import { z } from 'zod';
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
// Input/output validation
const appRouter = router({
userById: publicProcedure
.input(z.object({
id: z.string().uuid()
}))
.output(z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email()
}))
.query(async ({ input }) => {
const user = await db.user.findUnique({
where: { id: input.id }
});
return user; // Type-checked against output schema
}),
createUser: publicProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18)
}))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
})
});
export type AppRouter = typeof appRouter;
Next.js API Routes
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().min(18).optional()
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validatedData = CreateUserSchema.parse(body);
// validatedData is typed and validated
const user = await createUser(validatedData);
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ errors: error.flatten().fieldErrors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
// Query parameter validation
const SearchParamsSchema = z.object({
page: z.string().transform(Number).pipe(z.number().min(1)).default('1'),
limit: z.string().transform(Number).pipe(z.number().max(100)).default('10'),
sort: z.enum(['asc', 'desc']).default('asc')
});
export async function GET(request: NextRequest) {
const searchParams = Object.fromEntries(
request.nextUrl.searchParams.entries()
);
const params = SearchParamsSchema.parse(searchParams);
// params is { page: number, limit: number, sort: 'asc' | 'desc' }
const users = await getUsers(params);
return NextResponse.json(users);
}
Express Middleware
import express from 'express';
import { z } from 'zod';
// Validation middleware
const validate = (schema: z.ZodSchema) => {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
errors: error.flatten().fieldErrors
});
}
next(error);
}
};
};
const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().min(18)
});
app.post('/users', validate(CreateUserSchema), async (req, res) => {
// req.body is validated (not typed in Express)
const user = await createUser(req.body);
res.json(user);
});
// Validate params, query, body
const validateRequest = (schema: {
params?: z.ZodSchema;
query?: z.ZodSchema;
body?: z.ZodSchema;
}) => {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
if (schema.params) {
req.params = schema.params.parse(req.params);
}
if (schema.query) {
req.query = schema.query.parse(req.query);
}
if (schema.body) {
req.body = schema.body.parse(req.body);
}
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ errors: error.issues });
}
next(error);
}
};
};
app.get(
'/users/:id',
validateRequest({
params: z.object({ id: z.string().uuid() }),
query: z.object({ include: z.string().optional() })
}),
async (req, res) => {
// Validated params and query
}
);
Drizzle ORM
import { z } from 'zod';
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
// Define table
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
age: integer('age')
});
// Auto-generate schemas
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
// Customize validation
export const customInsertUserSchema = createInsertSchema(users, {
email: z.string().email(),
age: z.number().min(18).optional()
});
// Use in application
type NewUser = z.infer<typeof insertUserSchema>;
type User = z.infer<typeof selectUserSchema>;
function createUser(data: unknown) {
const validatedData = insertUserSchema.parse(data);
return db.insert(users).values(validatedData);
}
Environment Variables
// env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(32),
PORT: z.string().transform(Number).pipe(z.number().min(1024)),
REDIS_HOST: z.string().default('localhost'),
REDIS_PORT: z.string().transform(Number).default('6379'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info')
});
// Validate on startup
export const env = envSchema.parse(process.env);
// Type-safe environment variables
export type Env = z.infer<typeof envSchema>;
// Usage
console.log(`Server running on port ${env.PORT}`);
// env.PORT is number, not string
<!-- SECTION: best_practices -->
Best Practices
Schema Organization
// schemas/user.schema.ts
import { z } from 'zod';
// Reusable primitives
export const emailSchema = z.string().email();
export const uuidSchema = z.string().uuid();
export const passwordSchema = z.string()
.min(8)
.regex(/[A-Z]/, "Must contain uppercase")
.regex(/[0-9]/, "Must contain number");
// Base schemas
export const baseUserSchema = z.object({
id: uuidSchema,
email: emailSchema,
name: z.string().min(2)
});
// Extended schemas
export const createUserSchema = baseUserSchema.omit({ id: true }).extend({
password: passwordSchema,
confirmPassword: z.string()
}).refine(
data => data.password === data.confirmPassword,
{ message: "Passwords must match", path: ["confirmPassword"] }
);
export const updateUserSchema = baseUserSchema.partial().omit({ id: true });
// Export types
export type User = z.infer<typeof baseUserSchema>;
export type CreateUser = z.infer<typeof createUserSchema>;
export type UpdateUser = z.infer<typeof updateUserSchema>;
Performance Optimization
// Cache parsed schemas
const userSchemaCache = new Map<string, z.ZodSchema>();
function getCachedSchema(key: string, factory: () => z.ZodSchema) {
if (!userSchemaCache.has(key)) {
userSchemaCache.set(key, factory());
}
return userSchemaCache.get(key)!;
}
// Lazy validation for large objects
const lazyUserSchema = z.lazy(() => z.object({
// Only validated when accessed
profile: complexProfileSchema,
settings: complexSettingsSchema
}));
// Streaming validation for arrays
async function validateLargeArray(items: unknown[]) {
const errors: z.ZodError[] = [];
for (const item of items) {
const result = ItemSchema.safeParse(item);
if (!result.success) {
errors.push(result.error);
}
}
return errors;
}
Testing Schemas
import { describe, it, expect } from 'vitest';
describe('UserSchema', () => {
it('validates correct user data', () => {
const validUser = {
email: 'user@example.com',
name: 'John Doe',
age: 25
};
expect(() => UserSchema.parse(validUser)).not.toThrow();
});
it('rejects invalid email', () => {
const invalidUser = {
email: 'not-an-email',
name: 'John',
age: 25
};
const result = UserSchema.safeParse(invalidUser);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(['email']);
}
});
it('applies transforms correctly', () => {
const input = {
name: ' JOHN DOE ',
email: 'USER@EXAMPLE.COM'
};
const result = UserSchema.parse(input);
expect(result.name).toBe('john doe');
expect(result.email).toBe('user@example.com');
});
});
Common Patterns
// Conditional validation
const ConditionalSchema = z.object({
type: z.enum(['personal', 'business']),
data: z.any()
}).transform((val) => {
if (val.type === 'personal') {
return {
type: val.type,
data: PersonalDataSchema.parse(val.data)
};
} else {
return {
type: val.type,
data: BusinessDataSchema.parse(val.data)
};
}
});
// Pagination schema
export const paginationSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('asc')
});
// Filter schema
export const filterSchema = z.object({
search: z.string().optional(),
status: z.enum(['active', 'inactive', 'pending']).optional(),
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional()
});
// API response wrapper
export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
z.object({
success: z.boolean(),
data: dataSchema.optional(),
error: z.string().optional(),
timestamp: z.string().datetime()
});
const userResponseSchema = apiResponseSchema(UserSchema);
Migration from Yup/Joi
// Yup -> Zod
// Yup
const yupSchema = yup.object({
email: yup.string().email().required(),
age: yup.number().min(18).required()
});
// Zod equivalent
const zodSchema = z.object({
email: z.string().email(),
age: z.number().min(18)
});
// Joi -> Zod
// Joi
const joiSchema = Joi.object({
email: Joi.string().email().required(),
age: Joi.number().min(18).required()
});
// Zod equivalent (same as above)
const zodSchema = z.object({
email: z.string().email(),
age: z.number().min(18)
});
// Key differences:
// 1. Zod fields are required by default
// 2. Zod has first-class TypeScript integration
// 3. Zod schemas are immutable
// 4. Zod has better tree-shaking