Copied to Clipboard
Object Schemas
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email().toLowerCase(), // transform to lowercase
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
age: z.number().int().min(13).max(120).optional(),
createdAt: z.string().datetime(), // ISO 8601 string
})
type User = z.infer
// parse: throws ZodError on failure
const user = UserSchema.parse(rawData)
// safeParse: returns { success, data } or { success, error }
const result = UserSchema.safeParse(rawData)
if (!result.success) {
console.error(result.error.flatten())
// { fieldErrors: { email: ['Invalid email'] }, formErrors: [] }
}
Nested Objects and Arrays
const AddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zipCode: z.string().regex(/^d{5}(-d{4})?$/),
country: z.string().length(2).default('US'),
})
const OrderSchema = z.object({
id: z.string().uuid(),
customerId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
})
).min(1, 'Order must have at least one item'),
shippingAddress: AddressSchema,
total: z.number().positive(),
status: z.enum(['pending', 'confirmed', 'shipped', 'delivered', 'cancelled']),
notes: z.string().max(1000).nullable().default(null),
})
type Order = z.infer
// tuple with mixed types
const CoordSchema = z.tuple([z.number(), z.number()]) // [lat, lng]
const TripleSchema = z.tuple([z.string(), z.number(), z.boolean()])
Transforms: Parse and Reshape in One Step
// coerce strings from form inputs
const FormPriceSchema = z
.string()
.transform((val) => parseFloat(val))
.pipe(z.number().positive())
// parse ISO date strings into Date objects
const DateFromStringSchema = z
.string()
.datetime()
.transform((str) => new Date(str))
// normalize phone numbers
const PhoneSchema = z
.string()
.transform((val) => val.replace(/[^0-9]/g, ''))
.pipe(z.string().length(10, 'Must be 10 digits'))
// computed fields using transform
const FullNameSchema = z
.object({
firstName: z.string(),
lastName: z.string(),
})
.transform((data) => ({
...data,
fullName: `\${data.firstName} \${data.lastName}`,
initials: `\${data.firstName[0]}.\${data.lastName[0]}.`,
}))
const { firstName, lastName, fullName, initials } = FullNameSchema.parse({
firstName: 'Anup',
lastName: 'Karanjkar',
})
// fullName: 'Anup Karanjkar', initials: 'A.K.'
Refinements: Cross-Field and Async Validation
// single-field refinement
const PasswordSchema = z
.string()
.min(8)
.refine(
(val) => /[A-Z]/.test(val) && /[0-9]/.test(val) && /[^a-zA-Z0-9]/.test(val),
{ message: 'Password must contain uppercase, number, and special character' }
)
// cross-field refinement
const PasswordConfirmSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // attach error to specific field
})
// date range validation
const DateRangeSchema = z
.object({
startDate: z.string().date(),
endDate: z.string().date(),
})
.refine(
(data) => new Date(data.endDate) > new Date(data.startDate),
{ message: 'End date must be after start date', path: ['endDate'] }
)
// async refinement (e.g. database uniqueness check)
const UniqueEmailSchema = z
.string()
.email()
.superRefine(async (email, ctx) => {
const exists = await db.user.findUnique({ where: { email } })
if (exists) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Email already in use',
})
}
})
// use parseAsync for schemas with async refinements
const validEmail = await UniqueEmailSchema.parseAsync('user@example.com')
API Request Validation in Next.js Route Handlers
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const CreateProductSchema = z.object({
name: z.string().min(3).max(100),
price: z.number().positive(),
currency: z.enum(['USD', 'INR', 'EUR']).default('USD'),
description: z.string().min(10).max(5000),
tags: z.array(z.string()).max(10).default([]),
published: z.boolean().default(false),
})
type CreateProductInput = z.infer
export async function POST(req: NextRequest) {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = CreateProductSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
const product = await createProduct(result.data)
return NextResponse.json(product, { status: 201 })
}
// helper to validate any route handler input
function validateBody(schema: z.ZodType) {
return async (req: NextRequest): Promise => {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = schema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Validation failed', details: result.error.flatten().fieldErrors },
{ status: 422 }
)
}
return { data: result.data }
}
}
Form Validation with React Hook Form
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const CheckoutSchema = z.object({
email: z.string().email('Enter a valid email'),
firstName: z.string().min(1, 'Required').max(50),
lastName: z.string().min(1, 'Required').max(50),
address: z.string().min(5, 'Enter full address'),
city: z.string().min(1, 'Required'),
pinCode: z.string().regex(/^d{6}$/, 'Enter 6-digit PIN code'),
phone: z.string().regex(/^[6-9]d{9}$/, 'Enter valid Indian mobile number'),
})
type CheckoutFormData = z.infer
function CheckoutForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(CheckoutSchema),
})
const onSubmit = async (data: CheckoutFormData) => {
// data is fully typed AND validated
await submitOrder(data)
}
return (
{errors.email &&
{errors.email.message}
}
{errors.pinCode &&
{errors.pinCode.message}
}
{isSubmitting ? 'Processing...' : 'Place Order'}
)
}
Schema Composition and Reuse
// base schema — shared fields
const TimestampedSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
const BaseEntitySchema = z.object({
id: z.string().uuid(),
}).merge(TimestampedSchema)
// extend for specific entities
const ProductSchema = BaseEntitySchema.extend({
name: z.string(),
price: z.number().positive(),
})
// pick/omit for partial schemas
const ProductPreviewSchema = ProductSchema.pick({ id: true, name: true })
const CreateProductInput = ProductSchema.omit({ id: true, createdAt: true, updatedAt: true })
const UpdateProductInput = CreateProductInput.partial() // all fields optional
// discriminated unions
const ApiResponseSchema = z.discriminatedUnion('success', [
z.object({ success: z.literal(true), data: z.unknown() }),
z.object({ success: z.literal(false), error: z.string(), code: z.number() }),
])
type ApiResponse = z.infer
Environment Variable Validation
// src/env.ts — validate at startup, not at request time
import { z } from 'zod'
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().min(32),
RAZORPAY_KEY_ID: z.string().startsWith('rzp_'),
PORT: z.coerce.number().int().positive().default(3000),
})
const parsed = EnvSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid environment variables:')
console.error(parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
// env.PORT is typed as number, env.DATABASE_URL is typed as string
People Also Ask
What is the difference between parse and safeParse in Zod?
parse throws a ZodError if validation fails. Use it when you want the error to propagate up as an exception — common in server-side code where you have a top-level error handler. safeParse returns a discriminated union { success: true, data } | { success: false, error }. Use it when you need to handle validation failure gracefully without exceptions — typical for API request validation and form handling where you want to return structured error messages.
Can Zod schemas generate OpenAPI / JSON Schema documentation?
Yes, via the zod-to-json-schema package (npm install zod-to-json-schema). Call zodToJsonSchema(MySchema) to get a JSON Schema object you can plug into Swagger UI or any OpenAPI toolchain. For full OpenAPI 3.x spec generation, @asteasolutions/zod-to-openapi provides a registry-based API that produces complete path definitions including request bodies, query params, and response schemas.
How do I handle validation errors in a way that maps to form field errors?
Use error.flatten() on the ZodError. It returns { fieldErrors: Record<string, string[]>, formErrors: string[] }. Field errors are keyed by the field path (e.g., { email: ['Invalid email address'] }). If you are using React Hook Form with zodResolver, this mapping happens automatically — the resolver converts Zod errors into React Hook Form's error format and populates formState.errors for you.
Originally published at wowhow.cloud