12 KiB
12 KiB
Zod Validation Patterns
Common Zod validation patterns for API contracts, forms, and Server Actions.
String Validation
Basic String Constraints
import { z } from 'zod';
// Required string
const requiredString = z.string();
// Optional string
const optionalString = z.string().optional();
// String with length constraints
const username = z.string().min(3).max(20);
// Non-empty string
const nonEmpty = z.string().min(1);
// String with default value
const withDefault = z.string().default('default value');
Common String Formats
// Email validation
const email = z.string().email();
// URL validation
const url = z.string().url();
// UUID validation
const uuid = z.string().uuid();
// Custom regex pattern
const phoneNumber = z.string().regex(/^\+?[1-9]\d{1,14}$/);
// Alphanumeric only
const alphanumeric = z.string().regex(/^[a-zA-Z0-9]+$/);
// Trim whitespace
const trimmed = z.string().trim();
// Lowercase transformation
const lowercase = z.string().toLowerCase();
Advanced String Validation
// Custom refinement
const strongPassword = z.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val) && /[a-z]/.test(val) && /[0-9]/.test(val),
{ message: 'Password must contain uppercase, lowercase, and number' }
);
// Multiple validations with custom messages
const entityName = z.string()
.min(1, 'Name is required')
.max(100, 'Name must be 100 characters or less')
.regex(/^[a-zA-Z0-9\s-]+$/, 'Name can only contain letters, numbers, spaces, and hyphens');
Number Validation
// Basic number
const age = z.number();
// Integer only
const count = z.number().int();
// Positive number
const positive = z.number().positive();
// Non-negative (>= 0)
const nonNegative = z.number().nonnegative();
// Min/max constraints
const percentage = z.number().min(0).max(100);
// Multiple of
const evenNumber = z.number().multipleOf(2);
// Finite number (not Infinity or NaN)
const finite = z.number().finite();
// Convert string to number
const stringToNumber = z.coerce.number();
Boolean Validation
// Basic boolean
const isActive = z.boolean();
// Optional boolean with default
const isPublic = z.boolean().default(false);
// Coerce to boolean (accepts "true", "false", 1, 0)
const coercedBoolean = z.coerce.boolean();
Date Validation
// Basic date
const createdAt = z.date();
// Date with min/max constraints
const futureDate = z.date().min(new Date());
const pastDate = z.date().max(new Date());
// Date range
const dateInRange = z.date()
.min(new Date('2020-01-01'))
.max(new Date('2030-12-31'));
// Convert string to date
const stringToDate = z.coerce.date();
// ISO date string
const isoDate = z.string().datetime();
Array Validation
// Array of strings
const tags = z.array(z.string());
// Array with length constraints
const limitedTags = z.array(z.string()).min(1).max(10);
// Non-empty array
const nonEmptyArray = z.array(z.string()).nonempty();
// Array with unique items
const uniqueTags = z.array(z.string())
.refine((items) => new Set(items).size === items.length, {
message: 'Tags must be unique'
});
// Tuple (fixed-length array with different types)
const coordinate = z.tuple([z.number(), z.number()]);
Object Validation
// Basic object shape
const user = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
// Nested objects
const entity = z.object({
id: z.string(),
name: z.string(),
metadata: z.object({
createdBy: z.string(),
tags: z.array(z.string()),
}),
});
// Partial object (all fields optional)
const partialUser = user.partial();
// Pick specific fields
const userCredentials = user.pick({ email: true, password: true });
// Omit specific fields
const publicUser = user.omit({ password: true });
// Extend object
const extendedUser = user.extend({
role: z.string(),
});
// Merge objects
const merged = user.merge(z.object({ role: z.string() }));
Union and Intersection Types
// Union (one of multiple types)
const stringOrNumber = z.union([z.string(), z.number()]);
// Discriminated union
const result = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.any() }),
z.object({ status: z.literal('error'), error: z.string() }),
]);
// Intersection (combines multiple types)
const timestamped = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const userWithTimestamps = z.intersection(user, timestamped);
Enum and Literal Types
// Enum
const roleEnum = z.enum(['admin', 'user', 'guest']);
// Literal
const statusLiteral = z.literal('active');
// Multiple literals (union)
const status = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('pending'),
]);
// Native enum support
enum Role {
Admin = 'admin',
User = 'user',
Guest = 'guest',
}
const nativeEnum = z.nativeEnum(Role);
Optional and Nullable
// Optional (can be undefined)
const optional = z.string().optional();
// Nullable (can be null)
const nullable = z.string().nullable();
// Both optional and nullable
const optionalNullable = z.string().optional().nullable();
// Nullish (undefined or null)
const nullish = z.string().nullish();
Custom Error Messages
// Field-level messages
const username = z.string({
required_error: 'Username is required',
invalid_type_error: 'Username must be a string',
});
// Validation-level messages
const password = z.string()
.min(8, { message: 'Password must be at least 8 characters' })
.max(100, { message: 'Password is too long' });
// Custom refinement with message
const ageValidation = z.number()
.refine((val) => val >= 18, {
message: 'Must be 18 or older',
});
Transformations
// Transform to different type
const numberFromString = z.string().transform((val) => parseInt(val, 10));
// Preprocessing
const trimmedString = z.preprocess(
(val) => typeof val === 'string' ? val.trim() : val,
z.string()
);
// Chain transforms
const normalized = z.string()
.trim()
.toLowerCase()
.transform((val) => val.replace(/\s+/g, '-'));
Advanced Patterns
Conditional Validation
// Dependent fields
const formSchema = z.object({
hasAddress: z.boolean(),
address: z.string().optional(),
}).refine(
(data) => !data.hasAddress || (data.hasAddress && data.address),
{ message: 'Address is required when hasAddress is true', path: ['address'] }
);
Record/Map Validation
// Record with string keys
const metadata = z.record(z.string(), z.any());
// Record with specific value type
const scores = z.record(z.string(), z.number());
// Map
const map = z.map(z.string(), z.number());
Recursive Types
// Self-referencing type (tree structure)
interface Category {
id: string;
name: string;
children?: Category[];
}
const categorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
id: z.string(),
name: z.string(),
children: z.array(categorySchema).optional(),
})
);
Form Integration Patterns
React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
age: z.number().int().positive().optional(),
});
type FormData = z.infer<typeof formSchema>;
function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}
API Validation Patterns
Server Action with Validation
'use server';
import { z } from 'zod';
const createEntitySchema = z.object({
name: z.string().min(1).max(100),
type: z.enum(['character', 'location', 'item']),
description: z.string().optional(),
});
export async function createEntity(formData: FormData) {
const rawData = {
name: formData.get('name'),
type: formData.get('type'),
description: formData.get('description'),
};
// Validate with safeParse
const result = createEntitySchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Type-safe data
const { name, type, description } = result.data;
// Process validated data
// ...
return { success: true, data: { id: '123' } };
}
API Route with Validation
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const requestSchema = z.object({
query: z.string(),
filters: z.object({
type: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
}).optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = requestSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid request', details: result.error.format() },
{ status: 400 }
);
}
const { query, filters } = result.data;
// Process validated data
// ...
return NextResponse.json({ results: [] });
}
Worldbuilding-Specific Patterns
Entity Validation
const entitySchema = z.object({
id: z.string().uuid(),
type: z.enum(['character', 'location', 'item', 'faction', 'event']),
name: z.string().min(1).max(200),
description: z.string().max(5000).optional(),
tags: z.array(z.string()).max(20),
attributes: z.record(z.string(), z.any()),
metadata: z.object({
createdAt: z.date(),
updatedAt: z.date(),
createdBy: z.string(),
}),
});
Relationship Validation
const relationshipSchema = z.object({
id: z.string().uuid(),
sourceId: z.string().uuid(),
targetId: z.string().uuid(),
type: z.string().min(1).max(50),
strength: z.number().min(0).max(1).optional(),
metadata: z.record(z.string(), z.any()).optional(),
}).refine(
(data) => data.sourceId !== data.targetId,
{ message: 'Source and target must be different entities', path: ['targetId'] }
);
Timeline Event Validation
const timelineEventSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200),
date: z.object({
year: z.number().int(),
month: z.number().int().min(1).max(12).optional(),
day: z.number().int().min(1).max(31).optional(),
}),
description: z.string().max(5000).optional(),
entityIds: z.array(z.string().uuid()),
}).refine(
(data) => {
if (data.date.day && !data.date.month) {
return false;
}
return true;
},
{ message: 'Month is required when day is specified', path: ['date', 'month'] }
);
Best Practices
- Use
.safeParse()over.parse(): Prevents throwing exceptions - Provide clear error messages: Help users understand what went wrong
- Validate early: At API boundaries and form submissions
- Use type inference:
z.infer<typeof schema>for TypeScript types - Keep schemas colocated: Near the code that uses them
- Document complex validations: Add comments explaining business rules
- Test edge cases: Empty values, boundary conditions, invalid formats
- Use refinements for complex logic: When built-in validators aren't enough
- Consider performance: Complex validations on large datasets may be slow
- Version your schemas: When APIs evolve, maintain backward compatibility