Zod Schema Validation in React: Forms and API Responses (2026)
Zod is the de-facto TypeScript validation library. You define a schema once and get runtime validation, TypeScript types, and detailed error messages for free. This guide covers Zod fundamentals, integrating it with React Hook Form, validating API responses, discriminated unions for complex types, and custom validators.
Table of Contents
Zod Basics
import { z } from 'zod'
// Primitive schemas
const nameSchema = z.string().min(2).max(50)
const ageSchema = z.number().int().min(0).max(150)
const emailSchema = z.string().email()
const urlSchema = z.string().url()
const uuidSchema = z.string().uuid()
// Parse — throws ZodError on failure
const name = nameSchema.parse('Alice')
// safeParse — returns { success, data } | { success: false, error }
const result = emailSchema.safeParse('not-an-email')
if (!result.success) {
console.log(result.error.format())
// { _errors: ['Invalid email'] }
}
// Infer TypeScript types from schemas
type Name = z.infer<typeof nameSchema> // string
type Age = z.infer<typeof ageSchema> // number
// Optional and nullable
const bio = z.string().optional() // string | undefined
const avatar = z.string().nullable() // string | null
const note = z.string().nullish() // string | null | undefined
// Arrays and enums
const tags = z.array(z.string()).min(1).max(10)
const role = z.enum(['admin', 'user', 'guest'])
type Role = z.infer<typeof role> // 'admin' | 'user' | 'guest'
// Literals
const status = z.literal('active')
const trueLiteral = z.literal(true)
Objects and Nested Schemas
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
country: z.string().default('US'),
})
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18, 'Must be 18 or older'),
role: z.enum(['admin', 'user', 'guest']).default('user'),
address: addressSchema, // Nested object
tags: z.array(z.string()).default([]),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
})
type User = z.infer<typeof userSchema>
// Schema composition
const createUserSchema = userSchema.omit({ id: true, createdAt: true, updatedAt: true })
const updateUserSchema = userSchema.partial().required({ id: true })
const publicUserSchema = userSchema.pick({ id: true, name: true, role: true })
type CreateUserInput = z.infer<typeof createUserSchema>
type UpdateUserInput = z.infer<typeof updateUserSchema>
React Hook Form Integration
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const registerSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Enter a valid email'),
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number'),
confirmPassword: z.string(),
terms: z.literal(true, { errorMap: () => ({ message: 'You must accept the terms' }) }),
}).refine(
data => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] }
)
type RegisterForm = z.infer<typeof registerSchema>
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
defaultValues: { terms: false as unknown as true },
})
async function onSubmit(data: RegisterForm) {
try {
await registerUser(data)
} catch (err) {
// Map server errors back to form fields
if (err.field === 'email') {
setError('email', { message: 'This email is already registered' })
}
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="firstName">First Name</label>
<input id="firstName" {...register('firstName')} />
{errors.firstName && <p role="alert">{errors.firstName.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input id="confirmPassword" type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <p role="alert">{errors.confirmPassword.message}</p>}
</div>
<div>
<input id="terms" type="checkbox" {...register('terms')} />
<label htmlFor="terms">I accept the terms</label>
{errors.terms && <p role="alert">{errors.terms.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create Account'}
</button>
</form>
)
}
API Response Validation
// Validate API responses at runtime — catch backend contract breaks early
const productSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number().positive(),
inStock: z.boolean(),
category: z.string(),
images: z.array(z.string().url()),
createdAt: z.coerce.date(), // Coerce ISO string → Date automatically
})
const paginatedProductsSchema = z.object({
data: z.array(productSchema),
pagination: z.object({
page: z.number().int(),
pageSize: z.number().int(),
total: z.number().int(),
totalPages: z.number().int(),
}),
})
type Product = z.infer<typeof productSchema>
type PaginatedProducts = z.infer<typeof paginatedProductsSchema>
async function fetchProducts(page: number): Promise<PaginatedProducts> {
const res = await fetch(`/api/products?page=${page}`)
if (!res.ok) throw new Error('Failed to fetch products')
const json = await res.json()
// Validates AND transforms (e.g. coerces dates) in one step
return paginatedProductsSchema.parse(json)
}
// Usage with TanStack Query
function useProducts(page: number) {
return useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
// Type inferred automatically from the return type of fetchProducts
})
}
Discriminated Unions
// Model API responses that have different shapes based on a status field
const apiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.unknown(),
requestId: z.string(),
}),
z.object({
status: z.literal('error'),
code: z.string(),
message: z.string(),
details: z.record(z.string()).optional(),
}),
z.object({
status: z.literal('pending'),
jobId: z.string(),
estimatedMs: z.number().optional(),
}),
])
type ApiResponse = z.infer<typeof apiResponseSchema>
// Narrowing with discriminated unions
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'success':
return response.data // TypeScript knows this field exists
case 'error':
console.error(response.code, response.message)
break
case 'pending':
pollJob(response.jobId) // TypeScript knows this field exists
break
}
}
// Payment method example
const paymentMethodSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('card'), last4: z.string(), brand: z.string(), expiry: z.string() }),
z.object({ type: z.literal('bank'), routingNumber: z.string(), accountLast4: z.string() }),
z.object({ type: z.literal('paypal'), email: z.string().email() }),
])
Transforms and Preprocessing
// Transform data during parsing
const priceSchema = z
.string()
.transform(val => parseFloat(val.replace(/[$,]/g, '')))
.pipe(z.number().positive())
priceSchema.parse('$1,234.56') // → 1234.56
// Preprocess before validation
const booleanFromString = z.preprocess(
val => val === 'true' || val === true,
z.boolean()
)
booleanFromString.parse('true') // → true
booleanFromString.parse(true) // → true
// Coerce types (Zod 3.20+)
const dateSchema = z.coerce.date()
dateSchema.parse('2026-01-15') // → Date object
dateSchema.parse(1705276800000) // → Date object (from timestamp)
// Trim and normalize strings
const normalizedEmail = z.string()
.trim()
.toLowerCase()
.email()
// Form values are often strings — coerce numbers from form inputs
const formNumberSchema = z.coerce.number().int().min(1).max(100)
Custom Validators
// .refine() for single-field validation
const passwordSchema = z.string().refine(
password => {
const hasUpper = /[A-Z]/.test(password)
const hasLower = /[a-z]/.test(password)
const hasNumber = /\d/.test(password)
const hasSpecial = /[^a-zA-Z0-9]/.test(password)
return hasUpper && hasLower && hasNumber && hasSpecial
},
{ message: 'Password must contain uppercase, lowercase, number, and special character' }
)
// .superRefine() for multiple errors on the same field
const advancedPasswordSchema = z.string().superRefine((val, ctx) => {
if (val.length < 8) ctx.addIssue({ code: z.ZodIssueCode.too_small, minimum: 8, type: 'string', inclusive: true, message: 'At least 8 characters' })
if (!/[A-Z]/.test(val)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Must contain uppercase' })
if (!/\d/.test(val)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Must contain a number' })
})
// Cross-field validation with .refine()
const dateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(
data => data.endDate > data.startDate,
{ message: 'End date must be after start date', path: ['endDate'] }
)
// Async validation (e.g., check if username is taken)
const usernameSchema = z.string().min(3).refine(
async username => {
const taken = await checkUsernameExists(username)
return !taken
},
{ message: 'Username is already taken' }
)
// Use schema.parseAsync() for async schemas
Error Handling
const result = userSchema.safeParse(unknownData)
if (!result.success) {
// Flat format — simple error messages per path
const flat = result.error.flatten()
console.log(flat.fieldErrors)
// { email: ['Invalid email'], age: ['Must be 18 or older'] }
// Format — nested structure matching the schema shape
const formatted = result.error.format()
console.log(formatted.email?._errors)
// ['Invalid email']
// ZodError has an issues array with full details
result.error.issues.forEach(issue => {
console.log(issue.path, issue.message, issue.code)
})
}
// Map Zod errors to react-hook-form errors manually (if not using zodResolver)
function zodErrorToFormErrors(error: z.ZodError) {
const errors: Record<string, string> = {}
error.issues.forEach(issue => {
const path = issue.path.join('.')
if (!errors[path]) errors[path] = issue.message
})
return errors
}
Zod vs Yup: Both integrate well with React Hook Form via
@hookform/resolvers. Zod has better TypeScript inference (types flow automatically from schemas), a more composable API, and is actively developed. Yup has a longer history and may be more familiar from older projects. For new React + TypeScript projects, Zod is the current default recommendation.