
Zod Validation Patterns
- 1 installs
- 404 repo stars
- Updated August 5, 2026
- aiskillstore/marketplace
zod-validation-patterns is a skill that provides Zod validation patterns for TypeScript, covering schemas, refinements, transforms, async checks, and Next.js API integration.
About
zod-validation-patterns provides patterns for validating input with the Zod library in TypeScript applications. A developer uses it for API request validation, form and file-upload validation, data transformation, and type inference across schemas, refinements, transforms, and async checks. It includes Next.js API-route and Server-Action integration examples and guidance on when validation is and is not warranted.
- Zod validation patterns for TypeScript input validation
- Covers schemas, refinements, transforms, and async checks
- Next.js API-route and Server-Action integration examples
Zod Validation Patterns by the numbers
- 1 all-time installs (skills.sh)
- Ranked #3,830 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
zod-validation-patterns capabilities & compatibility
- Capabilities
- api development · backend · refactoring
- Use cases
- api development · refactoring
What zod-validation-patterns says it does
This skill provides comprehensive patterns for using Zod validation library in TypeScript applications.
npx skills add https://github.com/aiskillstore/marketplace --skill zod-validation-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 404 |
| Last updated | August 5, 2026 |
| Repository | aiskillstore/marketplace ↗ |
What it does
Apply Zod validation patterns for API, form, and data-input validation in TypeScript apps.
Who is it for?
Validating API requests, forms, and external data with Zod in TypeScript
Skip if: Simple type checks or re-validating already-trusted internal data
When should I use this skill?
Adding input, API, form, or file-upload validation in a TypeScript or Next.js app
What you get
Consistent, type-safe Zod validation at API, form, and data boundaries
- Reusable Zod schemas and validation patterns
By the numbers
- 8 reference guides: schema patterns, error handling, refinements, transforms, async, type inference, API integration, co
Files
Zod Validation Patterns Skill
Use this skill when: Working with user input validation, API request validation, form data validation, or data transformation in Quetrex.
Purpose
This skill provides comprehensive patterns for using Zod validation library in TypeScript applications. It ensures input validation is done correctly, securely, and consistently across the codebase.
What's Covered
1. [Schema Patterns](./schema-patterns.md) - Complete guide to all Zod schema types
- Primitives (string, number, boolean, date)
- Collections (array, object, map, set, record)
- Advanced types (union, intersection, discriminated unions)
- Optional/nullable patterns
- Branded types and recursive schemas
2. [Error Handling](./error-handling.md) - Robust error management
- Custom error messages
- Internationalization (i18n)
- Error formatting for UI display
- Safe parsing patterns
- Error recovery strategies
3. [Refinements](./refinements.md) - Custom validation logic
- Basic and chained refinements
- Cross-field validation
- Conditional validation
- Business logic validation
- File upload validation
4. [Transforms](./transforms.md) - Data transformation and normalization
- Type coercion
- Data cleaning and normalization
- Computed fields
- Preprocessing patterns
5. [Async Validation](./async-validation.md) - Asynchronous validation patterns
- Database uniqueness checks
- API validations
- Concurrent async validations
- Error handling and timeouts
6. [Type Inference](./type-inference.md) - TypeScript type extraction
- z.infer patterns
- Input vs output types
- Generic schema types
- Discriminated union inference
7. [API Integration](./api-integration.md) - Next.js integration patterns
- API routes validation
- Server Actions validation
- Form data and file uploads
- Error response formatting
8. [Common Schemas](./common-schemas.md) - Reusable schema library
- Email, password, phone validation
- URL, UUID, date schemas
- Address, credit card validation
- Username, slug, color schemas
Quick Start
Basic Usage
import { z } from 'zod'
// Define schema
const userSchema = z.object({
email: z.string().email(),
age: z.number().int().positive(),
role: z.enum(['admin', 'user'])
})
// Parse data (throws on error)
const user = userSchema.parse(data)
// Safe parse (returns result object)
const result = userSchema.safeParse(data)
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}Type Inference
// Extract TypeScript type from schema
type User = z.infer<typeof userSchema>
// { email: string; age: number; role: 'admin' | 'user' }API Route Example
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
export async function POST(request: NextRequest) {
const body = await request.json()
const result = createUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Validation failed', details: result.error.format() },
{ status: 400 }
)
}
// Process validated data
const { email, password } = result.data
// ...
}When to Use This Skill
DO Use for:
- API request validation - All incoming data to API routes
- Form submission validation - Client and server-side
- Database input validation - Before inserting/updating
- Configuration validation - Environment variables, config files
- File upload validation - Size, type, content validation
- External API responses - Validate third-party data
DON'T Use for:
- Simple type checks - Use TypeScript types when validation isn't needed
- Runtime performance-critical paths - Validation has overhead
- Already validated data - Don't re-validate trusted internal data
Best Practices
1. Validate at boundaries - API routes, Server Actions, external data sources 2. Use safe parsing - Prefer safeParse() over parse() for better error handling 3. Provide clear error messages - Customize messages for user-facing validation 4. Reuse common schemas - Use schemas from common-schemas.md 5. Type inference - Always use z.infer<typeof schema> for TypeScript types 6. Test edge cases - Write tests for validation logic 7. Document complex schemas - Add JSDoc comments for business rules
Common Patterns
1. Optional Fields with Defaults
const configSchema = z.object({
timeout: z.number().int().positive().default(30),
retries: z.number().int().min(0).default(3),
debug: z.boolean().optional()
})2. Conditional Required Fields
const addressSchema = z.object({
country: z.string(),
state: z.string().optional()
}).refine(
data => data.country === 'US' ? !!data.state : true,
{ message: 'State is required for US addresses', path: ['state'] }
)3. Transform and Validate
const emailSchema = z.string()
.trim()
.toLowerCase()
.email()4. Discriminated Unions
const eventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keypress'), key: z.string() })
])5. Async Database Check
const usernameSchema = z.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_-]+$/)
.refine(async (username) => {
const existing = await db.user.findUnique({ where: { username } })
return !existing
}, { message: 'Username already taken' })Integration with Quetrex
TypeScript Strict Mode Compliance
All schemas must work with TypeScript strict mode:
- No
anytypes - No
@ts-ignorecomments - Explicit type inference with
z.infer
Testing Requirements
Validation logic requires comprehensive tests:
- Happy path - Valid data passes
- Edge cases - Boundary values, empty strings, null/undefined
- Error cases - Invalid data produces expected errors
- Custom validations - All refinements and transforms tested
Server Actions Pattern
'use server'
import { z } from 'zod'
const createProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional()
})
export async function createProject(formData: FormData) {
const result = createProjectSchema.safeParse({
name: formData.get('name'),
description: formData.get('description')
})
if (!result.success) {
return { error: result.error.format() }
}
// Process validated data
return { success: true, data: result.data }
}Resources
- Zod Documentation: https://zod.dev/
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/
- Next.js Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
Navigation
Start with: 1. [Schema Patterns](./schema-patterns.md) - Learn all schema types 2. [Common Schemas](./common-schemas.md) - Use ready-made schemas 3. [API Integration](./api-integration.md) - Integrate with Next.js
Then explore:
- [Error Handling](./error-handling.md) - Better error messages
- [Refinements](./refinements.md) - Custom validation logic
- [Transforms](./transforms.md) - Data transformation
- [Async Validation](./async-validation.md) - Database/API checks
- [Type Inference](./type-inference.md) - Advanced TypeScript patterns
---
Last updated: 2025-11-23 | Zod v4.1.12
API Integration - Next.js Patterns
This document covers all patterns for integrating Zod validation with Next.js 15 App Router, API routes, Server Actions, and forms.
Table of Contents
- API Route Validation
- Server Actions Validation
- Middleware Validation
- Request Body Validation
- Query Parameter Validation
- Path Parameter Validation
- Form Data Validation
- File Upload Validation
- Error Response Formatting
- Try-Catch Patterns
---
API Route Validation
Basic API Route
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive()
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate
const result = createUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.format()
},
{ status: 400 }
)
}
// Process validated data
const user = await db.user.create({ data: result.data })
return NextResponse.json({ user }, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}GET with Query Params
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const getUsersQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10),
sort: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc')
})
export async function GET(request: NextRequest) {
const searchParams = Object.fromEntries(request.nextUrl.searchParams)
const result = getUsersQuerySchema.safeParse(searchParams)
if (!result.success) {
return NextResponse.json(
{ error: result.error.format() },
{ status: 400 }
)
}
const { page, limit, sort, order } = result.data
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { [sort]: order }
})
return NextResponse.json({ users, page, limit })
}Dynamic Route with Path Params
// src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const userIdSchema = z.string().uuid()
const updateUserSchema = z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional(),
age: z.number().int().positive().optional()
}).refine(
data => Object.keys(data).length > 0,
{ message: 'At least one field must be provided' }
)
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
// Validate path param
const idResult = userIdSchema.safeParse(params.id)
if (!idResult.success) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
)
}
// Validate body
const body = await request.json()
const bodyResult = updateUserSchema.safeParse(body)
if (!bodyResult.success) {
return NextResponse.json(
{ error: bodyResult.error.format() },
{ status: 400 }
)
}
const user = await db.user.update({
where: { id: idResult.data },
data: bodyResult.data
})
return NextResponse.json({ user })
}---
Server Actions Validation
Basic Server Action
// src/app/actions/users.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const createUserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
export async function createUser(formData: FormData) {
const result = createUserSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password')
})
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
try {
const user = await db.user.create({ data: result.data })
revalidatePath('/users')
return { success: true, data: user }
} catch (error) {
return {
success: false,
errors: { _form: ['Failed to create user'] }
}
}
}Server Action with Object Input
'use server'
import { z } from 'zod'
const updateProfileSchema = z.object({
userId: z.string().uuid(),
name: z.string().min(1).optional(),
bio: z.string().max(500).optional(),
website: z.string().url().optional()
})
export async function updateProfile(data: unknown) {
const result = updateProfileSchema.safeParse(data)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
const { userId, ...updates } = result.data
await db.user.update({
where: { id: userId },
data: updates
})
revalidatePath(`/users/${userId}`)
return { success: true }
}Server Action with File Upload
'use server'
import { z } from 'zod'
const uploadAvatarSchema = z.object({
userId: z.string().uuid(),
file: z.instanceof(File)
.refine(file => file.size <= 5 * 1024 * 1024, 'File must be less than 5MB')
.refine(
file => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type),
'Only JPEG, PNG, and WebP images are allowed'
)
})
export async function uploadAvatar(formData: FormData) {
const result = uploadAvatarSchema.safeParse({
userId: formData.get('userId'),
file: formData.get('file')
})
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
const { userId, file } = result.data
// Upload to storage
const url = await uploadToS3(file)
// Update database
await db.user.update({
where: { id: userId },
data: { avatar: url }
})
return { success: true, url }
}---
Middleware Validation
Authentication Middleware
// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
const authTokenSchema = z.string().regex(/^Bearer .+$/)
export function middleware(request: NextRequest) {
const authorization = request.headers.get('authorization')
const result = authTokenSchema.safeParse(authorization)
if (!result.success) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Verify token
const token = result.data.replace('Bearer ', '')
// ... verify token logic
return NextResponse.next()
}
export const config = {
matcher: '/api/:path*'
}Rate Limiting Middleware
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
const rateLimitHeaderSchema = z.object({
'x-user-id': z.string().uuid()
})
export async function middleware(request: NextRequest) {
const headers = {
'x-user-id': request.headers.get('x-user-id')
}
const result = rateLimitHeaderSchema.safeParse(headers)
if (!result.success) {
return NextResponse.json(
{ error: 'Missing user ID header' },
{ status: 400 }
)
}
const userId = result.data['x-user-id']
// Check rate limit
const allowed = await checkRateLimit(userId)
if (!allowed) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
)
}
return NextResponse.next()
}---
Request Body Validation
JSON Body
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const postSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).max(10).optional(),
published: z.boolean().default(false)
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validated = postSchema.parse(body)
const post = await db.post.create({ data: validated })
return NextResponse.json({ post }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.format() },
{ status: 400 }
)
}
throw error
}
}Nested Objects
const createOrderSchema = z.object({
customer: z.object({
name: z.string(),
email: z.string().email(),
phone: z.string().optional()
}),
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
price: z.number().positive()
})).min(1),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
state: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
country: z.string()
})
})
export async function POST(request: NextRequest) {
const body = await request.json()
const result = createOrderSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: result.error.format() },
{ status: 400 }
)
}
// All nested data is validated
const order = await createOrder(result.data)
return NextResponse.json({ order }, { status: 201 })
}---
Query Parameter Validation
Simple Query Params
const searchSchema = z.object({
q: z.string().min(1),
category: z.string().optional(),
minPrice: z.coerce.number().positive().optional(),
maxPrice: z.coerce.number().positive().optional()
})
export async function GET(request: NextRequest) {
const searchParams = Object.fromEntries(request.nextUrl.searchParams)
const result = searchSchema.safeParse(searchParams)
if (!result.success) {
return NextResponse.json(
{ error: result.error.format() },
{ status: 400 }
)
}
const products = await searchProducts(result.data)
return NextResponse.json({ products })
}Pagination and Sorting
const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
sortBy: z.enum(['createdAt', 'updatedAt', 'name']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc')
})
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams)
const validated = paginationSchema.parse(params)
const items = await db.item.findMany({
skip: (validated.page - 1) * validated.limit,
take: validated.limit,
orderBy: { [validated.sortBy]: validated.sortOrder }
})
return NextResponse.json({ items })
}Filters
const filterSchema = z.object({
status: z.enum(['active', 'inactive', 'pending']).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
tags: z.string().transform(val => val.split(',')).optional()
})
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams)
const filters = filterSchema.parse(params)
const where: any = {}
if (filters.status) where.status = filters.status
if (filters.startDate) where.createdAt = { gte: filters.startDate }
if (filters.endDate) where.createdAt = { ...where.createdAt, lte: filters.endDate }
if (filters.tags) where.tags = { hasSome: filters.tags }
const items = await db.item.findMany({ where })
return NextResponse.json({ items })
}---
Path Parameter Validation
UUID Parameter
// src/app/api/posts/[id]/route.ts
const postIdSchema = z.string().uuid()
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const result = postIdSchema.safeParse(params.id)
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid post ID format' },
{ status: 400 }
)
}
const post = await db.post.findUnique({ where: { id: result.data } })
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return NextResponse.json({ post })
}Slug Parameter
// src/app/api/posts/slug/[slug]/route.ts
const slugSchema = z.string().regex(/^[a-z0-9-]+$/)
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
const validated = slugSchema.parse(params.slug)
const post = await db.post.findUnique({ where: { slug: validated } })
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return NextResponse.json({ post })
}Multiple Parameters
// src/app/api/users/[userId]/posts/[postId]/route.ts
const paramsSchema = z.object({
userId: z.string().uuid(),
postId: z.string().uuid()
})
export async function GET(
request: NextRequest,
{ params }: { params: { userId: string; postId: string } }
) {
const validated = paramsSchema.parse(params)
const post = await db.post.findFirst({
where: {
id: validated.postId,
authorId: validated.userId
}
})
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
)
}
return NextResponse.json({ post })
}---
Form Data Validation
Basic Form Data
'use server'
const contactFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message must be at least 10 characters')
})
export async function submitContactForm(formData: FormData) {
const result = contactFormSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message')
})
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
await sendEmail(result.data)
return { success: true }
}Form with Checkboxes
const preferencesSchema = z.object({
newsletter: z.string().transform(val => val === 'on').default('false'),
notifications: z.string().transform(val => val === 'on').default('false'),
marketing: z.string().transform(val => val === 'on').default('false')
})
export async function updatePreferences(formData: FormData) {
const result = preferencesSchema.safeParse({
newsletter: formData.get('newsletter'),
notifications: formData.get('notifications'),
marketing: formData.get('marketing')
})
if (!result.success) {
return { success: false, errors: result.error.format() }
}
await db.user.update({
where: { id: userId },
data: result.data
})
return { success: true }
}Form with Array Fields
const multipleFilesSchema = z.object({
files: z.array(z.instanceof(File)).min(1).max(10)
})
export async function uploadFiles(formData: FormData) {
const files = formData.getAll('files')
const result = multipleFilesSchema.safeParse({ files })
if (!result.success) {
return { success: false, errors: result.error.format() }
}
const uploadedUrls = await Promise.all(
result.data.files.map(file => uploadToS3(file))
)
return { success: true, urls: uploadedUrls }
}---
File Upload Validation
Single File Upload
'use server'
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const fileUploadSchema = z.object({
file: z.instanceof(File)
.refine(file => file.size <= MAX_FILE_SIZE, 'File must be less than 5MB')
.refine(
file => ALLOWED_TYPES.includes(file.type),
'Only JPEG, PNG, and WebP images allowed'
)
})
export async function uploadFile(formData: FormData) {
const result = fileUploadSchema.safeParse({
file: formData.get('file')
})
if (!result.success) {
return { success: false, errors: result.error.format() }
}
const url = await uploadToS3(result.data.file)
return { success: true, url }
}Multiple Files with Validation
const multipleImagesSchema = z.object({
images: z.array(z.instanceof(File))
.min(1, 'At least one image is required')
.max(10, 'Maximum 10 images allowed')
.refine(
files => files.every(file => file.size <= MAX_FILE_SIZE),
'Each file must be less than 5MB'
)
.refine(
files => files.every(file => ALLOWED_TYPES.includes(file.type)),
'All files must be JPEG, PNG, or WebP images'
)
})
export async function uploadGallery(formData: FormData) {
const images = formData.getAll('images')
const result = multipleImagesSchema.safeParse({ images })
if (!result.success) {
return { success: false, errors: result.error.format() }
}
const urls = await Promise.all(
result.data.images.map(uploadToS3)
)
return { success: true, urls }
}---
Error Response Formatting
Standard Error Format
type ErrorResponse = {
error: string
details?: Record<string, string[]>
code?: string
}
function formatZodError(error: z.ZodError): ErrorResponse {
return {
error: 'Validation failed',
details: error.flatten().fieldErrors,
code: 'VALIDATION_ERROR'
}
}
export async function POST(request: NextRequest) {
const body = await request.json()
const result = schema.safeParse(body)
if (!result.success) {
return NextResponse.json(
formatZodError(result.error),
{ status: 400 }
)
}
// Process
}Detailed Error Format
function formatDetailedErrors(error: z.ZodError) {
return {
success: false,
errors: error.errors.map(err => ({
path: err.path.join('.'),
message: err.message,
code: err.code
}))
}
}User-Friendly Errors
function formatUserFriendlyErrors(error: z.ZodError) {
const fieldErrors: Record<string, string> = {}
error.errors.forEach(err => {
const field = err.path.join('.')
if (!fieldErrors[field]) {
fieldErrors[field] = err.message
}
})
return {
success: false,
message: 'Please check the form for errors',
fields: fieldErrors
}
}---
Try-Catch Patterns
API Route Error Handling
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Validate
const validated = schema.parse(body)
// Process
const result = await processData(validated)
return NextResponse.json({ result }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.format() },
{ status: 400 }
)
}
if (error instanceof DatabaseError) {
return NextResponse.json(
{ error: 'Database error' },
{ status: 500 }
)
}
console.error('Unexpected error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}Server Action Error Handling
'use server'
export async function createPost(formData: FormData) {
try {
const validated = postSchema.parse({
title: formData.get('title'),
content: formData.get('content')
})
const post = await db.post.create({ data: validated })
revalidatePath('/posts')
return { success: true, data: post }
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
errors: error.flatten().fieldErrors
}
}
return {
success: false,
errors: { _form: ['Failed to create post'] }
}
}
}---
Summary
This document covered:
- ✅ API route validation (GET, POST, PATCH, DELETE)
- ✅ Server Actions with FormData and objects
- ✅ Middleware validation
- ✅ Request body, query params, and path params
- ✅ Form data and file upload validation
- ✅ Error response formatting
- ✅ Try-catch patterns for robust error handling
Next Steps:
- [Common Schemas](./common-schemas.md) - Ready-to-use validation schemas
- [Error Handling](./error-handling.md) - Advanced error patterns
- [Schema Patterns](./schema-patterns.md) - All schema types
---
Last updated: 2025-11-23 | Zod v4.1.12
Async Validation - Asynchronous Validation Patterns
This document covers all asynchronous validation patterns in Zod for database checks, API calls, and other async operations.
Table of Contents
- Async Refinement
- Async Schema
- Email Existence Validation
- URL Reachability Check
- Async Dependent Validation
- Error Handling
- Timeout Handling
- Concurrent Async Validations
- Caching Async Results
---
Async Refinement
Basic Async Refinement
import { z } from 'zod'
// Async database check
const emailSchema = z.string()
.email()
.refine(
async (email) => {
const user = await db.user.findUnique({ where: { email } })
return !user // Return false if user exists
},
{ message: 'Email is already registered' }
)
// Usage
const result = await emailSchema.parseAsync('user@example.com')
// Or with safeParse
const safeResult = await emailSchema.safeParseAsync('user@example.com')Async with Custom Error
const usernameSchema = z.string()
.min(3)
.max(20)
.refine(
async (username) => {
const existing = await db.user.findUnique({ where: { username } })
return !existing
},
async (username) => ({
message: `Username "${username}" is already taken`
})
)Multiple Async Refinements
const userSchema = z.object({
email: z.string().email(),
username: z.string()
})
.refine(
async (data) => {
const emailExists = await db.user.findUnique({ where: { email: data.email } })
return !emailExists
},
{ message: 'Email already registered', path: ['email'] }
)
.refine(
async (data) => {
const usernameExists = await db.user.findUnique({ where: { username: data.username } })
return !usernameExists
},
{ message: 'Username already taken', path: ['username'] }
)
// Usage - must use parseAsync
const result = await userSchema.parseAsync({
email: 'user@example.com',
username: 'johndoe'
})---
Async Schema
Promise Schema
// Validate a promise that resolves to a value
const promiseSchema = z.promise(z.string())
// Example
const myPromise = Promise.resolve('hello')
const result = await promiseSchema.parse(myPromise) // 'hello'Async Function Return Type
async function fetchUser(id: string): Promise<{ name: string; email: string }> {
// Fetch from database
return await db.user.findUnique({ where: { id } })
}
const userSchema = z.object({
name: z.string(),
email: z.string().email()
})
const userPromiseSchema = z.promise(userSchema)
// Validate async function result
const user = await fetchUser('123')
const validated = userSchema.parse(user)---
Email Existence Validation
Check Email Domain
import dns from 'dns/promises'
const emailWithDomainCheckSchema = z.string()
.email()
.refine(
async (email) => {
const domain = email.split('@')[1]
try {
const records = await dns.resolveMx(domain)
return records.length > 0
} catch {
return false
}
},
{ message: 'Email domain does not exist' }
)Check Email via API
async function verifyEmail(email: string): Promise<boolean> {
try {
const response = await fetch(`https://api.emailverification.com/verify?email=${email}`)
const data = await response.json()
return data.valid
} catch {
return false
}
}
const verifiedEmailSchema = z.string()
.email()
.refine(
verifyEmail,
{ message: 'Email address could not be verified' }
)Check Disposable Email
const DISPOSABLE_DOMAINS = new Set([
'tempmail.com',
'10minutemail.com',
// ... more
])
async function isDisposableEmail(email: string): Promise<boolean> {
const domain = email.split('@')[1]
// Check local blacklist
if (DISPOSABLE_DOMAINS.has(domain)) {
return true
}
// Check external API
try {
const response = await fetch(`https://api.disposable-email.com/check/${domain}`)
const data = await response.json()
return data.disposable
} catch {
return false
}
}
const nonDisposableEmailSchema = z.string()
.email()
.refine(
async (email) => !(await isDisposableEmail(email)),
{ message: 'Disposable email addresses are not allowed' }
)---
URL Reachability Check
Check URL Exists
async function isUrlReachable(url: string): Promise<boolean> {
try {
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
return response.ok
} catch {
return false
}
}
const reachableUrlSchema = z.string()
.url()
.refine(
isUrlReachable,
{ message: 'URL is not reachable' }
)Check Image URL
async function isValidImageUrl(url: string): Promise<boolean> {
try {
const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
const contentType = response.headers.get('content-type')
return contentType?.startsWith('image/') ?? false
} catch {
return false
}
}
const imageUrlSchema = z.string()
.url()
.refine(
isValidImageUrl,
{ message: 'URL must point to a valid image' }
)Check API Endpoint
async function isValidApiEndpoint(url: string): Promise<boolean> {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(5000) })
return response.status < 500 // Accept any non-server error
} catch {
return false
}
}
const apiEndpointSchema = z.string()
.url()
.refine(
isValidApiEndpoint,
{ message: 'API endpoint is not available' }
)---
Async Dependent Validation
Cross-Table Validation
const orderSchema = z.object({
userId: z.string(),
productId: z.string(),
quantity: z.number().positive()
})
.refine(
async (data) => {
// Check if user exists
const user = await db.user.findUnique({ where: { id: data.userId } })
return !!user
},
{ message: 'User not found', path: ['userId'] }
)
.refine(
async (data) => {
// Check if product exists
const product = await db.product.findUnique({ where: { id: data.productId } })
return !!product
},
{ message: 'Product not found', path: ['productId'] }
)
.refine(
async (data) => {
// Check if enough stock
const product = await db.product.findUnique({ where: { id: data.productId } })
return product && product.stock >= data.quantity
},
{ message: 'Insufficient stock', path: ['quantity'] }
)Permission Validation
const actionSchema = z.object({
userId: z.string(),
resourceId: z.string(),
action: z.enum(['read', 'write', 'delete'])
})
.refine(
async (data) => {
const user = await db.user.findUnique({
where: { id: data.userId },
include: { permissions: true }
})
if (!user) return false
const resource = await db.resource.findUnique({
where: { id: data.resourceId }
})
if (!resource) return false
// Check permission
return user.permissions.some(p =>
p.resourceId === data.resourceId &&
p.action === data.action
)
},
{ message: 'Permission denied' }
)Rate Limit Validation
async function checkRateLimit(userId: string): Promise<boolean> {
const key = `rate_limit:${userId}`
const count = await redis.get(key)
if (!count) {
await redis.setex(key, 60, '1') // 1 request in 60 seconds
return true
}
const requests = parseInt(count)
if (requests >= 10) {
return false
}
await redis.incr(key)
return true
}
const rateLimitedSchema = z.object({
userId: z.string(),
// ... other fields
})
.refine(
async (data) => await checkRateLimit(data.userId),
{ message: 'Rate limit exceeded. Please try again later.' }
)---
Error Handling
Try-Catch in Refinement
const safeAsyncSchema = z.string()
.refine(
async (value) => {
try {
const result = await externalApiCall(value)
return result.valid
} catch (error) {
console.error('Validation error:', error)
return false // Treat errors as validation failure
}
},
{ message: 'Validation failed' }
)Fallback on Error
const resilientSchema = z.string()
.refine(
async (value) => {
try {
return await primaryValidation(value)
} catch {
try {
return await fallbackValidation(value)
} catch {
return false
}
}
},
{ message: 'All validation methods failed' }
)Detailed Error Messages
const detailedErrorSchema = z.string()
.refine(
async (value) => {
try {
const result = await validateWithApi(value)
return result.valid
} catch (error) {
throw new Error(`Validation service error: ${error.message}`)
}
},
{ message: 'External validation failed' }
)---
Timeout Handling
With AbortController
async function validateWithTimeout(value: string, timeoutMs: number): Promise<boolean> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(`https://api.example.com/validate?value=${value}`, {
signal: controller.signal
})
clearTimeout(timeout)
return response.ok
} catch (error) {
clearTimeout(timeout)
if (error.name === 'AbortError') {
console.log('Validation timeout')
return false
}
throw error
}
}
const timeoutSchema = z.string()
.refine(
async (value) => await validateWithTimeout(value, 5000),
{ message: 'Validation timeout or failed' }
)With Promise.race
async function timeoutPromise<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
)
return Promise.race([promise, timeout])
}
const racingSchema = z.string()
.refine(
async (value) => {
try {
await timeoutPromise(externalValidation(value), 3000)
return true
} catch {
return false
}
},
{ message: 'Validation timeout' }
)---
Concurrent Async Validations
Parallel Validations
const parallelSchema = z.object({
email: z.string().email(),
username: z.string(),
slug: z.string()
})
.refine(
async (data) => {
// Run all checks in parallel
const [emailExists, usernameExists, slugExists] = await Promise.all([
db.user.findUnique({ where: { email: data.email } }),
db.user.findUnique({ where: { username: data.username } }),
db.post.findUnique({ where: { slug: data.slug } })
])
return !emailExists && !usernameExists && !slugExists
},
{ message: 'One or more fields already exist' }
)Parallel with Individual Errors
const parallelErrorsSchema = z.object({
email: z.string().email(),
username: z.string()
})
.refine(
async (data) => {
const emailExists = await db.user.findUnique({ where: { email: data.email } })
return !emailExists
},
{ message: 'Email already registered', path: ['email'] }
)
.refine(
async (data) => {
const usernameExists = await db.user.findUnique({ where: { username: data.username } })
return !usernameExists
},
{ message: 'Username already taken', path: ['username'] }
)
// Both refinements run in parallel automaticallyOptimized Parallel Validation
async function validateUserData(data: { email: string; username: string; phone: string }) {
const [emailExists, usernameExists, phoneExists] = await Promise.all([
db.user.findUnique({ where: { email: data.email } }),
db.user.findUnique({ where: { username: data.username } }),
db.user.findUnique({ where: { phone: data.phone } })
])
const errors: Array<{ path: string[]; message: string }> = []
if (emailExists) errors.push({ path: ['email'], message: 'Email already registered' })
if (usernameExists) errors.push({ path: ['username'], message: 'Username taken' })
if (phoneExists) errors.push({ path: ['phone'], message: 'Phone number registered' })
return { valid: errors.length === 0, errors }
}
const optimizedSchema = z.object({
email: z.string().email(),
username: z.string(),
phone: z.string()
})
.refine(
async (data) => {
const result = await validateUserData(data)
return result.valid
},
{ message: 'Validation failed' }
)---
Caching Async Results
Simple Cache
const cache = new Map<string, boolean>()
const cachedSchema = z.string()
.refine(
async (value) => {
// Check cache first
if (cache.has(value)) {
return cache.get(value)!
}
// Perform validation
const result = await expensiveValidation(value)
// Cache result
cache.set(value, result)
return result
},
{ message: 'Validation failed' }
)TTL Cache
interface CacheEntry {
value: boolean
expires: number
}
const ttlCache = new Map<string, CacheEntry>()
async function cachedValidation(value: string, ttlMs: number): Promise<boolean> {
const now = Date.now()
const cached = ttlCache.get(value)
if (cached && cached.expires > now) {
return cached.value
}
const result = await expensiveValidation(value)
ttlCache.set(value, {
value: result,
expires: now + ttlMs
})
return result
}
const ttlCachedSchema = z.string()
.refine(
async (value) => await cachedValidation(value, 60000), // 1 minute TTL
{ message: 'Validation failed' }
)LRU Cache
class LRUCache<K, V> {
private cache = new Map<K, V>()
constructor(private maxSize: number) {}
get(key: K): V | undefined {
const value = this.cache.get(key)
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key)
this.cache.set(key, value)
}
return value
}
set(key: K, value: V): void {
this.cache.delete(key)
this.cache.set(key, value)
if (this.cache.size > this.maxSize) {
// Remove oldest (first) entry
const firstKey = this.cache.keys().next().value
this.cache.delete(firstKey)
}
}
}
const lruCache = new LRUCache<string, boolean>(100)
const lruCachedSchema = z.string()
.refine(
async (value) => {
const cached = lruCache.get(value)
if (cached !== undefined) {
return cached
}
const result = await expensiveValidation(value)
lruCache.set(value, result)
return result
},
{ message: 'Validation failed' }
)Redis Cache
import { Redis } from 'ioredis'
const redis = new Redis()
async function redisCachedValidation(value: string): Promise<boolean> {
const cacheKey = `validation:${value}`
// Check cache
const cached = await redis.get(cacheKey)
if (cached !== null) {
return cached === 'true'
}
// Perform validation
const result = await expensiveValidation(value)
// Cache for 1 hour
await redis.setex(cacheKey, 3600, result.toString())
return result
}
const redisCachedSchema = z.string()
.refine(
redisCachedValidation,
{ message: 'Validation failed' }
)---
Advanced Async Patterns
Debounced Validation
function debounce<T extends (...args: any[]) => any>(
func: T,
waitMs: number
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>): Promise<ReturnType<T>> => {
return new Promise((resolve) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => resolve(func(...args)), waitMs)
})
}
}
const debouncedCheck = debounce(expensiveValidation, 500)
const debouncedSchema = z.string()
.refine(
async (value) => await debouncedCheck(value),
{ message: 'Validation failed' }
)Batched Validation
let batchQueue: string[] = []
let batchTimeout: NodeJS.Timeout | null = null
async function batchValidation(values: string[]): Promise<Map<string, boolean>> {
// Validate all values in single API call
const response = await fetch('https://api.example.com/validate/batch', {
method: 'POST',
body: JSON.stringify({ values })
})
const results = await response.json()
return new Map(results)
}
async function queuedValidation(value: string): Promise<boolean> {
return new Promise((resolve) => {
batchQueue.push(value)
if (batchTimeout) clearTimeout(batchTimeout)
batchTimeout = setTimeout(async () => {
const queue = [...batchQueue]
batchQueue = []
const results = await batchValidation(queue)
resolve(results.get(value) ?? false)
}, 100) // Batch every 100ms
})
}Retry on Failure
async function retryValidation(
value: string,
maxRetries: number = 3
): Promise<boolean> {
for (let i = 0; i < maxRetries; i++) {
try {
return await externalValidation(value)
} catch (error) {
if (i === maxRetries - 1) throw error
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))) // Exponential backoff
}
}
return false
}
const retrySchema = z.string()
.refine(
async (value) => await retryValidation(value),
{ message: 'Validation failed after retries' }
)---
Summary
This document covered:
- ✅ Async refinements for database checks
- ✅ Promise schema validation
- ✅ Email existence and domain validation
- ✅ URL reachability checks
- ✅ Cross-table and permission validation
- ✅ Error and timeout handling
- ✅ Concurrent validations for performance
- ✅ Caching strategies (TTL, LRU, Redis)
- ✅ Advanced patterns (debounce, batch, retry)
Next Steps:
- [Type Inference](./type-inference.md) - TypeScript types
- [API Integration](./api-integration.md) - Use in Next.js
- [Common Schemas](./common-schemas.md) - Ready-to-use patterns
---
Last updated: 2025-11-23 | Zod v4.1.12
Common Schemas - Reusable Validation Library
This document provides 15+ production-ready validation schemas that can be imported and used directly in your application.
Table of Contents
- Email Schema
- Password Schema
- Phone Schema
- URL Schema
- UUID Schema
- CUID Schema
- Date Schema
- Currency Schema
- Address Schema
- Credit Card Schema
- Username Schema
- Slug Schema
- Hex Color Schema
- IP Address Schema
- JSON Schema
- File Upload Schema
- Pagination Schema
- Social Media Handles
- Tax ID Schemas
- Timezone Schema
---
Email Schema
Basic Email
import { z } from 'zod'
export const emailSchema = z.string()
.trim()
.toLowerCase()
.email('Invalid email address')
// Usage
type Email = z.infer<typeof emailSchema>Business Email (Company Domain)
export function createBusinessEmailSchema(domain: string) {
return z.string()
.email()
.refine(
email => email.endsWith(`@${domain}`),
{ message: `Email must be from ${domain} domain` }
)
}
// Usage
const companyEmailSchema = createBusinessEmailSchema('company.com')Email with Disposable Check
const DISPOSABLE_DOMAINS = new Set([
'tempmail.com',
'10minutemail.com',
'guerrillamail.com',
'mailinator.com',
'throwaway.email'
])
export const verifiedEmailSchema = z.string()
.email()
.refine(
email => {
const domain = email.split('@')[1]
return !DISPOSABLE_DOMAINS.has(domain)
},
{ message: 'Disposable email addresses are not allowed' }
)---
Password Schema
Strong Password
export const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password is too long')
.refine(
val => /[A-Z]/.test(val),
{ message: 'Password must contain at least one uppercase letter' }
)
.refine(
val => /[a-z]/.test(val),
{ message: 'Password must contain at least one lowercase letter' }
)
.refine(
val => /[0-9]/.test(val),
{ message: 'Password must contain at least one number' }
)
.refine(
val => /[^A-Za-z0-9]/.test(val),
{ message: 'Password must contain at least one special character' }
)
// Usage
type Password = z.infer<typeof passwordSchema>Password with Confirmation
export const passwordWithConfirmationSchema = z.object({
password: passwordSchema,
confirmPassword: z.string()
}).refine(
data => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ['confirmPassword']
}
)
type PasswordWithConfirmation = z.infer<typeof passwordWithConfirmationSchema>Password Strength Levels
export const weakPasswordSchema = z.string().min(6)
export const mediumPasswordSchema = z.string()
.min(8)
.refine(val => /[A-Z]/.test(val) && /[a-z]/.test(val))
export const strongPasswordSchema = passwordSchema // From above---
Phone Schema
US Phone Number
export const usPhoneSchema = z.string()
.regex(/^\+?1?\s*\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/, 'Invalid US phone number')
.transform(phone => phone.replace(/\D/g, '')) // Remove non-digits
// Accepts: (555) 123-4567, 555-123-4567, 5551234567, +1 555 123 4567
// Returns: 5551234567International Phone Number
export const internationalPhoneSchema = z.string()
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number format')
.transform(phone => phone.replace(/\D/g, ''))
// E.164 format: +1234567890Phone with Country Code
export const phoneWithCountrySchema = z.object({
countryCode: z.string().regex(/^\+\d{1,3}$/),
number: z.string().regex(/^\d{6,14}$/)
}).transform(data => `${data.countryCode}${data.number}`)
// Usage
type PhoneWithCountry = z.infer<typeof phoneWithCountrySchema>
// Input: { countryCode: '+1', number: '5551234567' }
// Output: '+15551234567'---
URL Schema
Basic URL
export const urlSchema = z.string()
.url('Invalid URL format')
.transform(url => {
// Ensure https if no protocol
if (!url.match(/^https?:\/\//i)) {
return `https://${url}`
}
return url
})HTTPS Only
export const secureUrlSchema = z.string()
.url()
.refine(
url => url.startsWith('https://'),
{ message: 'URL must use HTTPS' }
)URL with Path Validation
export function createUrlWithPathSchema(allowedPaths: string[]) {
return z.string()
.url()
.refine(
url => {
const pathname = new URL(url).pathname
return allowedPaths.some(path => pathname.startsWith(path))
},
{ message: 'URL path not allowed' }
)
}
// Usage
const apiUrlSchema = createUrlWithPathSchema(['/api/v1', '/api/v2'])---
UUID Schema
UUID v4
export const uuidSchema = z.string().uuid('Invalid UUID format')
type UUID = z.infer<typeof uuidSchema>Branded UUID
export const userIdSchema = z.string().uuid().brand<'UserId'>()
export const postIdSchema = z.string().uuid().brand<'PostId'>()
export const commentIdSchema = z.string().uuid().brand<'CommentId'>()
type UserId = z.infer<typeof userIdSchema>
type PostId = z.infer<typeof postIdSchema>
type CommentId = z.infer<typeof commentIdSchema>
// These types are incompatible with each other
function getUser(id: UserId) { /* ... */ }
getUser(userIdSchema.parse('...')) // ✅ Works
// getUser(postIdSchema.parse('...')) // ❌ Type error---
CUID Schema
CUID v2
export const cuidSchema = z.string().cuid2('Invalid CUID format')
type CUID = z.infer<typeof cuidSchema>Branded CUID
export const resourceIdSchema = z.string().cuid2().brand<'ResourceId'>()
type ResourceId = z.infer<typeof resourceIdSchema>---
Date Schema
ISO Date String
export const isoDateSchema = z.string()
.datetime('Invalid ISO date format')
.transform(str => new Date(str))
type ISODate = z.infer<typeof isoDateSchema>Date Range
export const dateRangeSchema = z.object({
startDate: z.date(),
endDate: z.date()
}).refine(
data => data.endDate >= data.startDate,
{
message: 'End date must be after or equal to start date',
path: ['endDate']
}
)
type DateRange = z.infer<typeof dateRangeSchema>Future Date Only
export const futureDateSchema = z.date().min(
new Date(),
'Date must be in the future'
)Past Date Only
export const pastDateSchema = z.date().max(
new Date(),
'Date must be in the past'
)Birthdate (18+ validation)
function calculateAge(birthdate: Date): number {
const today = new Date()
let age = today.getFullYear() - birthdate.getFullYear()
const monthDiff = today.getMonth() - birthdate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthdate.getDate())) {
age--
}
return age
}
export const birthdateSchema = z.date()
.max(new Date(), 'Birthdate cannot be in the future')
.refine(
date => calculateAge(date) >= 18,
{ message: 'You must be at least 18 years old' }
)
type Birthdate = z.infer<typeof birthdateSchema>---
Currency Schema
USD Currency
export const usdCurrencySchema = z.number()
.positive('Amount must be positive')
.multipleOf(0.01, 'Amount must have at most 2 decimal places')
.max(999999.99, 'Amount is too large')
type USDAmount = z.infer<typeof usdCurrencySchema>Multi-Currency
export const currencySchema = z.object({
amount: z.number().positive().multipleOf(0.01),
currency: z.enum(['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD'])
})
type Currency = z.infer<typeof currencySchema>
// Usage
const price: Currency = { amount: 19.99, currency: 'USD' }Price Range
export function createPriceRangeSchema(min: number, max: number) {
return z.number()
.positive()
.multipleOf(0.01)
.min(min, `Price must be at least $${min}`)
.max(max, `Price cannot exceed $${max}`)
}
const productPriceSchema = createPriceRangeSchema(0.01, 9999.99)---
Address Schema
US Address
export const usAddressSchema = z.object({
street: z.string().min(1, 'Street address is required'),
street2: z.string().optional(),
city: z.string().min(1, 'City is required'),
state: z.string().length(2, 'State must be 2-letter code').toUpperCase(),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
country: z.literal('US')
})
type USAddress = z.infer<typeof usAddressSchema>International Address
export const internationalAddressSchema = z.object({
street: z.string().min(1),
street2: z.string().optional(),
city: z.string().min(1),
state: z.string().optional(),
postalCode: z.string().min(1),
country: z.string().length(2) // ISO 3166-1 alpha-2
})
type InternationalAddress = z.infer<typeof internationalAddressSchema>Full Address with Validation
export const fullAddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().optional(),
zipCode: z.string().optional(),
country: z.string().length(2)
}).refine(
data => {
// US addresses require state and zip
if (data.country === 'US') {
return !!data.state && !!data.zipCode
}
return true
},
{
message: 'US addresses require state and ZIP code',
path: ['state']
}
)---
Credit Card Schema
Credit Card Number (Luhn Algorithm)
function luhnCheck(cardNumber: string): boolean {
const digits = cardNumber.replace(/\D/g, '')
let sum = 0
let isEven = false
for (let i = digits.length - 1; i >= 0; i--) {
let digit = parseInt(digits[i], 10)
if (isEven) {
digit *= 2
if (digit > 9) digit -= 9
}
sum += digit
isEven = !isEven
}
return sum % 10 === 0
}
export const creditCardNumberSchema = z.string()
.regex(/^\d{13,19}$/, 'Invalid card number format')
.refine(luhnCheck, { message: 'Invalid credit card number' })
type CreditCardNumber = z.infer<typeof creditCardNumberSchema>CVV
export const cvvSchema = z.string().regex(/^\d{3,4}$/, 'CVV must be 3 or 4 digits')Expiry Date
export const expiryDateSchema = z.object({
month: z.number().int().min(1).max(12),
year: z.number().int()
}).refine(
data => {
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
if (data.year < currentYear) return false
if (data.year === currentYear && data.month < currentMonth) return false
return true
},
{
message: 'Card has expired',
path: ['month']
}
)
type ExpiryDate = z.infer<typeof expiryDateSchema>Full Credit Card
export const fullCreditCardSchema = z.object({
number: creditCardNumberSchema,
cvv: cvvSchema,
expiry: expiryDateSchema,
holderName: z.string().min(1, 'Cardholder name is required')
})
type FullCreditCard = z.infer<typeof fullCreditCardSchema>---
Username Schema
Basic Username
export const usernameSchema = z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(
/^[a-zA-Z0-9_-]+$/,
'Username can only contain letters, numbers, underscores, and hyphens'
)
.transform(val => val.toLowerCase())
type Username = z.infer<typeof usernameSchema>Username with Reserved Check
const RESERVED_USERNAMES = new Set([
'admin', 'root', 'system', 'moderator',
'support', 'help', 'api', 'www'
])
export const validatedUsernameSchema = usernameSchema
.refine(
username => !RESERVED_USERNAMES.has(username.toLowerCase()),
{ message: 'This username is reserved' }
)---
Slug Schema
URL-Safe Slug
export const slugSchema = z.string()
.min(1)
.max(100)
.regex(
/^[a-z0-9-]+$/,
'Slug can only contain lowercase letters, numbers, and hyphens'
)
.refine(
slug => !slug.startsWith('-') && !slug.endsWith('-'),
{ message: 'Slug cannot start or end with a hyphen' }
)
type Slug = z.infer<typeof slugSchema>Generate Slug from Title
export const titleToSlugSchema = z.string()
.min(1)
.transform(title =>
title
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
)
.pipe(slugSchema)
// Usage
const slug = titleToSlugSchema.parse('Hello World! 123') // 'hello-world-123'---
Hex Color Schema
Hex Color Code
export const hexColorSchema = z.string()
.regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format')
.transform(val => val.toUpperCase())
type HexColor = z.infer<typeof hexColorSchema>
// Usage
const color = hexColorSchema.parse('#ff5733') // '#FF5733'Hex Color with Alpha
export const hexColorAlphaSchema = z.string()
.regex(/^#[0-9A-Fa-f]{8}$/, 'Invalid hex color format with alpha')
.transform(val => val.toUpperCase())
// Example: #FF5733FFRGB Color
export const rgbColorSchema = z.object({
r: z.number().int().min(0).max(255),
g: z.number().int().min(0).max(255),
b: z.number().int().min(0).max(255)
})
type RGBColor = z.infer<typeof rgbColorSchema>---
IP Address Schema
IPv4
export const ipv4Schema = z.string().ip({ version: 'v4' })
type IPv4 = z.infer<typeof ipv4Schema>IPv6
export const ipv6Schema = z.string().ip({ version: 'v6' })
type IPv6 = z.infer<typeof ipv6Schema>IP Address (v4 or v6)
export const ipAddressSchema = z.string().ip()
type IPAddress = z.infer<typeof ipAddressSchema>---
JSON Schema
Valid JSON String
export const jsonStringSchema = z.string().transform((str, ctx) => {
try {
return JSON.parse(str)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid JSON'
})
return z.NEVER
}
})
type JSONString = z.infer<typeof jsonStringSchema>JSON with Type Validation
export function createTypedJsonSchema<T extends z.ZodTypeAny>(schema: T) {
return z.string()
.transform((str, ctx) => {
try {
return JSON.parse(str)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid JSON'
})
return z.NEVER
}
})
.pipe(schema)
}
// Usage
const userJsonSchema = createTypedJsonSchema(z.object({
name: z.string(),
email: z.string().email()
}))---
File Upload Schema
Image File
const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
export const imageFileSchema = z.instanceof(File)
.refine(
file => file.size <= MAX_IMAGE_SIZE,
{ message: 'Image must be less than 5MB' }
)
.refine(
file => ALLOWED_IMAGE_TYPES.includes(file.type),
{ message: 'Only JPEG, PNG, and WebP images are allowed' }
)
type ImageFile = z.infer<typeof imageFileSchema>Document File
const MAX_DOC_SIZE = 10 * 1024 * 1024 // 10MB
const ALLOWED_DOC_TYPES = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
]
export const documentFileSchema = z.instanceof(File)
.refine(
file => file.size <= MAX_DOC_SIZE,
{ message: 'Document must be less than 10MB' }
)
.refine(
file => ALLOWED_DOC_TYPES.includes(file.type),
{ message: 'Only PDF and Word documents are allowed' }
)Avatar Upload
export const avatarSchema = z.instanceof(File)
.refine(file => file.size <= 2 * 1024 * 1024, 'Avatar must be less than 2MB')
.refine(
file => ['image/jpeg', 'image/png'].includes(file.type),
'Only JPEG and PNG images allowed'
)
.refine(
async file => {
// Check dimensions
const img = await createImageBitmap(file)
return img.width >= 200 && img.height >= 200
},
'Image must be at least 200x200 pixels'
)---
Pagination Schema
Basic Pagination
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20)
})
type Pagination = z.infer<typeof paginationSchema>Pagination with Sorting
export function createPaginationSchema<T extends string>(
sortFields: readonly [T, ...T[]]
) {
return z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
sortBy: z.enum(sortFields).default(sortFields[0]),
sortOrder: z.enum(['asc', 'desc']).default('desc')
})
}
// Usage
const userPaginationSchema = createPaginationSchema([
'createdAt', 'name', 'email'
] as const)
type UserPagination = z.infer<typeof userPaginationSchema>Cursor-Based Pagination
export const cursorPaginationSchema = z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().positive().max(100).default(20)
})
type CursorPagination = z.infer<typeof cursorPaginationSchema>---
Social Media Handles
Twitter Handle
export const twitterHandleSchema = z.string()
.min(1)
.max(15)
.regex(/^@?[a-zA-Z0-9_]+$/, 'Invalid Twitter handle')
.transform(handle => handle.startsWith('@') ? handle.slice(1) : handle)
type TwitterHandle = z.infer<typeof twitterHandleSchema>Instagram Handle
export const instagramHandleSchema = z.string()
.min(1)
.max(30)
.regex(/^@?[a-zA-Z0-9._]+$/, 'Invalid Instagram handle')
.transform(handle => handle.startsWith('@') ? handle.slice(1) : handle)GitHub Username
export const githubUsernameSchema = z.string()
.min(1)
.max(39)
.regex(/^[a-zA-Z0-9-]+$/, 'Invalid GitHub username')---
Tax ID Schemas
US SSN
export const ssnSchema = z.string()
.regex(/^\d{3}-\d{2}-\d{4}$/, 'SSN must be in format XXX-XX-XXXX')
.refine(
val => {
const [area, group, serial] = val.split('-')
return area !== '000' &&
area !== '666' &&
parseInt(area) < 900 &&
group !== '00' &&
serial !== '0000'
},
{ message: 'Invalid SSN number' }
)
type SSN = z.infer<typeof ssnSchema>US EIN
export const einSchema = z.string()
.regex(/^\d{2}-\d{7}$/, 'EIN must be in format XX-XXXXXXX')
.refine(
val => {
const prefix = parseInt(val.split('-')[0])
return prefix >= 1 && prefix <= 99
},
{ message: 'Invalid EIN number' }
)
type EIN = z.infer<typeof einSchema>---
Timezone Schema
IANA Timezone
const COMMON_TIMEZONES = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
'Asia/Tokyo',
'Australia/Sydney'
] as const
export const timezoneSchema = z.enum(COMMON_TIMEZONES)
type Timezone = z.infer<typeof timezoneSchema>UTC Offset
export const utcOffsetSchema = z.string()
.regex(/^[+-]\d{2}:\d{2}$/, 'Invalid UTC offset format')
// Examples: +05:30, -08:00---
Usage Examples
Import and Use
import { emailSchema, passwordSchema, usernameSchema } from '@/lib/schemas'
const registerSchema = z.object({
username: usernameSchema,
email: emailSchema,
password: passwordSchema
})
// Extend existing schemas
const extendedEmailSchema = emailSchema
.refine(async email => {
const exists = await checkEmailExists(email)
return !exists
}, 'Email already registered')Combine Schemas
import { addressSchema, phoneSchema, emailSchema } from '@/lib/schemas'
const contactInfoSchema = z.object({
email: emailSchema,
phone: phoneSchema,
address: addressSchema
})Create Schema Library File
// src/lib/schemas/index.ts
export * from './email'
export * from './password'
export * from './phone'
export * from './address'
export * from './payment'
export * from './user'
// ... etc---
Summary
This document provided:
- ✅ 20+ production-ready validation schemas
- ✅ Email, password, phone validation
- ✅ URL, UUID, date schemas
- ✅ Address and payment validation
- ✅ File upload schemas
- ✅ Pagination patterns
- ✅ Social media and tax ID validation
- ✅ All schemas ready to copy and use
Next Steps:
- [API Integration](./api-integration.md) - Use schemas in Next.js
- [Schema Patterns](./schema-patterns.md) - Learn all schema types
- [Type Inference](./type-inference.md) - Extract TypeScript types
---
Last updated: 2025-11-23 | Zod v4.1.12
Error Handling - Comprehensive Guide
This document covers every error handling pattern in Zod for robust validation error management.
Table of Contents
- Default Error Messages
- Custom Error Messages
- Error Map Customization
- Internationalization (i18n)
- Error Formatting for UI
- Error Flattening
- Parsing Errors
- Safe Parsing
- Try-Catch Patterns
- Error Recovery
---
Default Error Messages
Built-in Error Messages
import { z } from 'zod'
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(18)
})
try {
userSchema.parse({
email: 'invalid',
age: 15
})
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors)
// [
// {
// code: 'invalid_string',
// validation: 'email',
// message: 'Invalid email',
// path: ['email']
// },
// {
// code: 'too_small',
// minimum: 18,
// type: 'number',
// inclusive: true,
// message: 'Number must be greater than or equal to 18',
// path: ['age']
// }
// ]
}
}Error Codes Reference
// String validation errors
z.string().email() // code: 'invalid_string', validation: 'email'
z.string().url() // code: 'invalid_string', validation: 'url'
z.string().uuid() // code: 'invalid_string', validation: 'uuid'
z.string().regex(/.../) // code: 'invalid_string', validation: 'regex'
// Number validation errors
z.number().min(5) // code: 'too_small', type: 'number'
z.number().max(100) // code: 'too_big', type: 'number'
z.number().int() // code: 'invalid_type', expected: 'integer'
// Array validation errors
z.array(z.string()).min(1) // code: 'too_small', type: 'array'
z.array(z.string()).max(10) // code: 'too_big', type: 'array'
// Required field errors
z.string() // code: 'invalid_type', expected: 'string', received: 'undefined'
// Type mismatch
z.number() // code: 'invalid_type', expected: 'number', received: 'string'---
Custom Error Messages
Per-Field Custom Messages
// String validation with custom message
const emailSchema = z.string().email('Please enter a valid email address')
const urlSchema = z.string().url('Please enter a valid URL')
const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters long')
.max(100, 'Password is too long')Number Validation Messages
const ageSchema = z.number()
.min(18, 'You must be at least 18 years old')
.max(120, 'Please enter a valid age')
const priceSchema = z.number()
.positive('Price must be positive')
.multipleOf(0.01, 'Price must have at most 2 decimal places')Array Validation Messages
const tagsSchema = z.array(z.string())
.min(1, 'Please add at least one tag')
.max(10, 'You can add up to 10 tags')
.nonempty('Tags cannot be empty')Schema-Level Custom Messages
const userSchema = z.object({
email: z.string({
required_error: 'Email is required',
invalid_type_error: 'Email must be a string'
}).email('Invalid email format'),
age: z.number({
required_error: 'Age is required',
invalid_type_error: 'Age must be a number'
}).min(18, 'Must be 18 or older'),
acceptTerms: z.boolean({
required_error: 'You must accept the terms',
invalid_type_error: 'Invalid value'
})
})---
Error Map Customization
Global Error Map
import { z } from 'zod'
// Custom error map for all schemas
z.setErrorMap((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` }
}
}
// Use default message
return { message: ctx.defaultError }
})Schema-Specific Error Map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.invalid_type:
return { message: `Expected ${issue.expected}, got ${issue.received}` }
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return { message: 'Please provide a valid email address' }
}
if (issue.validation === 'url') {
return { message: 'Please provide a valid URL' }
}
break
case z.ZodIssueCode.too_small:
if (issue.type === 'string') {
return { message: `Must be at least ${issue.minimum} characters` }
}
if (issue.type === 'number') {
return { message: `Must be at least ${issue.minimum}` }
}
if (issue.type === 'array') {
return { message: `Must contain at least ${issue.minimum} items` }
}
break
case z.ZodIssueCode.too_big:
if (issue.type === 'string') {
return { message: `Must be at most ${issue.maximum} characters` }
}
if (issue.type === 'number') {
return { message: `Must be at most ${issue.maximum}` }
}
if (issue.type === 'array') {
return { message: `Must contain at most ${issue.maximum} items` }
}
break
case z.ZodIssueCode.invalid_enum_value:
return {
message: `Must be one of: ${issue.options.join(', ')}`
}
case z.ZodIssueCode.custom:
return { message: issue.message || 'Invalid value' }
}
return { message: ctx.defaultError }
}
// Use with specific schema
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(18)
}, { errorMap: customErrorMap })Error Map with Context
const contextualErrorMap: z.ZodErrorMap = (issue, ctx) => {
// Access path for field-specific messages
const fieldName = issue.path.join('.')
if (issue.code === z.ZodIssueCode.invalid_type) {
return {
message: `The field "${fieldName}" must be a ${issue.expected}`
}
}
if (issue.code === z.ZodIssueCode.too_small && issue.type === 'string') {
return {
message: `"${fieldName}" must be at least ${issue.minimum} characters`
}
}
return { message: ctx.defaultError }
}---
Internationalization (i18n)
Multi-Language Error Messages
type Language = 'en' | 'es' | 'fr'
const translations = {
en: {
required: 'This field is required',
email: 'Invalid email address',
minLength: (min: number) => `Must be at least ${min} characters`,
maxLength: (max: number) => `Must be at most ${max} characters`,
minValue: (min: number) => `Must be at least ${min}`,
maxValue: (max: number) => `Must be at most ${max}`
},
es: {
required: 'Este campo es obligatorio',
email: 'Correo electrónico inválido',
minLength: (min: number) => `Debe tener al menos ${min} caracteres`,
maxLength: (max: number) => `Debe tener como máximo ${max} caracteres`,
minValue: (min: number) => `Debe ser al menos ${min}`,
maxValue: (max: number) => `Debe ser como máximo ${max}`
},
fr: {
required: 'Ce champ est requis',
email: 'Adresse e-mail invalide',
minLength: (min: number) => `Doit contenir au moins ${min} caractères`,
maxLength: (max: number) => `Doit contenir au plus ${max} caractères`,
minValue: (min: number) => `Doit être au moins ${min}`,
maxValue: (max: number) => `Doit être au plus ${max}`
}
}
function createI18nErrorMap(lang: Language): z.ZodErrorMap {
const t = translations[lang]
return (issue, ctx) => {
switch (issue.code) {
case z.ZodIssueCode.invalid_type:
if (issue.received === 'undefined') {
return { message: t.required }
}
break
case z.ZodIssueCode.invalid_string:
if (issue.validation === 'email') {
return { message: t.email }
}
break
case z.ZodIssueCode.too_small:
if (issue.type === 'string') {
return { message: t.minLength(issue.minimum as number) }
}
if (issue.type === 'number') {
return { message: t.minValue(issue.minimum as number) }
}
break
case z.ZodIssueCode.too_big:
if (issue.type === 'string') {
return { message: t.maxLength(issue.maximum as number) }
}
if (issue.type === 'number') {
return { message: t.maxValue(issue.maximum as number) }
}
break
}
return { message: ctx.defaultError }
}
}
// Usage
const spanishErrorMap = createI18nErrorMap('es')
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(18)
}, { errorMap: spanishErrorMap })i18n with Library Integration
// Example with i18next
import i18next from 'i18next'
const i18nErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_string) {
if (issue.validation === 'email') {
return { message: i18next.t('validation.email') }
}
}
if (issue.code === z.ZodIssueCode.too_small && issue.type === 'string') {
return {
message: i18next.t('validation.minLength', { min: issue.minimum })
}
}
return { message: ctx.defaultError }
}
// Set globally
z.setErrorMap(i18nErrorMap)---
Error Formatting for UI
Format for Form Display
import { z } from 'zod'
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword']
})
// Parse and format errors
function validateUser(data: unknown) {
const result = userSchema.safeParse(data)
if (!result.success) {
// Format errors for UI
const fieldErrors: Record<string, string> = {}
result.error.errors.forEach(err => {
const path = err.path.join('.')
if (!fieldErrors[path]) {
fieldErrors[path] = err.message
}
})
return { success: false, errors: fieldErrors }
}
return { success: true, data: result.data }
}
// Usage in React component
function UserForm() {
const [errors, setErrors] = useState<Record<string, string>>({})
const handleSubmit = (data: unknown) => {
const result = validateUser(data)
if (!result.success) {
setErrors(result.errors)
return
}
// Process result.data
}
return (
<form>
<input name="email" />
{errors.email && <span className="error">{errors.email}</span>}
<input name="password" type="password" />
{errors.password && <span className="error">{errors.password}</span>}
</form>
)
}Format for API Response
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const body = await request.json()
const result = userSchema.safeParse(body)
if (!result.success) {
// Format errors for API response
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.format()
},
{ status: 400 }
)
}
// Process valid data
return NextResponse.json({ success: true })
}Format with Nested Objects
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{5}$/)
})
const userWithAddressSchema = z.object({
name: z.string(),
address: addressSchema
})
function formatErrors(error: z.ZodError) {
const formatted: Record<string, string> = {}
error.errors.forEach(err => {
const path = err.path.join('.')
formatted[path] = err.message
})
return formatted
}
// Example output:
// {
// "name": "Name is required",
// "address.street": "Street is required",
// "address.zipCode": "Invalid zip code format"
// }---
Error Flattening
Flatten Nested Errors
const complexSchema = z.object({
user: z.object({
profile: z.object({
name: z.string(),
email: z.string().email()
}),
settings: z.object({
theme: z.enum(['light', 'dark'])
})
})
})
try {
complexSchema.parse({
user: {
profile: { name: '', email: 'invalid' },
settings: { theme: 'invalid' }
}
})
} catch (error) {
if (error instanceof z.ZodError) {
// Flatten errors
const flattened = error.flatten()
console.log(flattened)
// {
// formErrors: [],
// fieldErrors: {
// 'user.profile.name': ['String must contain at least 1 character(s)'],
// 'user.profile.email': ['Invalid email'],
// 'user.settings.theme': ['Invalid enum value...']
// }
// }
}
}Field Errors vs Form Errors
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
}).refine(data => {
// Form-level validation
return someGlobalCheck(data)
}, {
message: 'Form validation failed'
// No path = form-level error
})
const result = schema.safeParse(data)
if (!result.success) {
const { fieldErrors, formErrors } = result.error.flatten()
// fieldErrors: { email: [...], password: [...] }
// formErrors: ['Form validation failed']
}Custom Flattening
function flattenErrors(error: z.ZodError): Record<string, string[]> {
const errors: Record<string, string[]> = {}
error.errors.forEach(err => {
const path = err.path.join('.') || '_form'
if (!errors[path]) {
errors[path] = []
}
errors[path].push(err.message)
})
return errors
}
// Usage
try {
schema.parse(data)
} catch (error) {
if (error instanceof z.ZodError) {
const flattened = flattenErrors(error)
// { "email": ["Invalid email"], "age": ["Must be 18+"] }
}
}---
Parsing Errors
ZodError Structure
import { z } from 'zod'
try {
const schema = z.string().email()
schema.parse('invalid')
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues) // Array of issues
console.log(error.errors) // Same as issues
console.log(error.message) // Formatted error message
console.log(error.format()) // Nested error object
console.log(error.flatten()) // Flattened errors
}
}Individual Issue Structure
const issue = {
code: 'invalid_string', // Error code
validation: 'email', // Validation type
message: 'Invalid email', // Error message
path: ['email'], // Path to field
// Additional fields depending on error type
}Accessing Error Details
const userSchema = z.object({
email: z.string().email(),
age: z.number().min(18).max(120)
})
try {
userSchema.parse({ email: 'invalid', age: 15 })
} catch (error) {
if (error instanceof z.ZodError) {
// Iterate through all errors
error.errors.forEach(err => {
console.log(`Field: ${err.path.join('.')}`)
console.log(`Message: ${err.message}`)
console.log(`Code: ${err.code}`)
// Type-specific details
if (err.code === 'too_small') {
console.log(`Minimum: ${err.minimum}`)
}
})
}
}---
Safe Parsing
safeParse() vs parse()
const schema = z.string().email()
// parse() - throws on error
try {
const result = schema.parse('invalid')
console.log(result)
} catch (error) {
console.error(error)
}
// safeParse() - returns result object
const result = schema.safeParse('invalid')
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}Safe Parse in API Routes
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
const result = userSchema.safeParse(body)
if (!result.success) {
// Handle validation error
return NextResponse.json(
{ error: result.error.format() },
{ status: 400 }
)
}
// Process valid data
const validData = result.data
// ...
}Safe Parse in Server Actions
'use server'
export async function createUser(formData: FormData) {
const result = userSchema.safeParse({
email: formData.get('email'),
password: formData.get('password')
})
if (!result.success) {
// Return errors to client
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
// Process valid data
return { success: true, data: result.data }
}Type Guards with Safe Parse
function isValidUser(data: unknown): data is User {
return userSchema.safeParse(data).success
}
// Usage
if (isValidUser(unknownData)) {
// TypeScript knows this is User
console.log(unknownData.email)
}---
Try-Catch Patterns
Basic Try-Catch
function validateAndProcess(data: unknown) {
try {
const validated = userSchema.parse(data)
// Process validated data
return { success: true, data: validated }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, errors: error.errors }
}
// Handle unexpected errors
throw error
}
}Try-Catch with Specific Error Handling
async function createUser(data: unknown) {
try {
const validated = userSchema.parse(data)
// Attempt to create user
const user = await db.user.create({ data: validated })
return { success: true, user }
} catch (error) {
if (error instanceof z.ZodError) {
// Validation error
return {
success: false,
type: 'validation',
errors: error.format()
}
}
// Database error, network error, etc.
return {
success: false,
type: 'unknown',
message: error instanceof Error ? error.message : 'Unknown error'
}
}
}Nested Try-Catch for Multi-Step Validation
async function processOrder(orderData: unknown, paymentData: unknown) {
try {
// Step 1: Validate order
const order = orderSchema.parse(orderData)
try {
// Step 2: Validate payment
const payment = paymentSchema.parse(paymentData)
// Process both
return await createOrder(order, payment)
} catch (error) {
if (error instanceof z.ZodError) {
return { error: 'Invalid payment data', details: error.errors }
}
throw error
}
} catch (error) {
if (error instanceof z.ZodError) {
return { error: 'Invalid order data', details: error.errors }
}
throw error
}
}---
Error Recovery
Fallback Values with catch()
// Provide default value on parse error
const safeNumberSchema = z.number().catch(0)
safeNumberSchema.parse(123) // 123
safeNumberSchema.parse('invalid') // 0
// Computed fallback
const timestampSchema = z.date().catch(() => new Date())
// Context-aware fallback
const userIdSchema = z.string().uuid().catch((ctx) => {
console.log('Invalid input:', ctx.input)
return crypto.randomUUID()
})Partial Validation
// Allow partial object validation
const userSchema = z.object({
email: z.string().email(),
age: z.number(),
name: z.string()
})
const partialUserSchema = userSchema.partial()
// All fields optional
const result = partialUserSchema.parse({
email: 'user@example.com'
// age and name can be missing
})Best-Effort Parsing
function bestEffortParse<T>(
schema: z.ZodType<T>,
data: unknown
): { data: Partial<T>; errors: z.ZodError | null } {
const result = schema.safeParse(data)
if (result.success) {
return { data: result.data, errors: null }
}
// Try to salvage what we can
const partial: any = {}
const originalData = data as Record<string, unknown>
for (const key in originalData) {
try {
// Try to validate individual fields
const fieldSchema = (schema as any).shape?.[key]
if (fieldSchema) {
partial[key] = fieldSchema.parse(originalData[key])
}
} catch {
// Skip invalid fields
}
}
return { data: partial, errors: result.error }
}Graceful Degradation
async function loadUserProfile(userId: string) {
try {
const data = await fetchUserProfile(userId)
const validated = userProfileSchema.parse(data)
return { type: 'full', data: validated }
} catch (error) {
if (error instanceof z.ZodError) {
// Try with minimal schema
const minimalSchema = userProfileSchema.pick({
id: true,
name: true
})
try {
const minimal = minimalSchema.parse(data)
return { type: 'minimal', data: minimal }
} catch {
// Can't even get minimal data
return { type: 'error', error }
}
}
throw error
}
}Error Logging and Monitoring
function parseWithLogging<T>(
schema: z.ZodType<T>,
data: unknown,
context: string
) {
const result = schema.safeParse(data)
if (!result.success) {
// Log validation failures
logger.warn('Validation failed', {
context,
errors: result.error.errors,
data: JSON.stringify(data)
})
// Send to monitoring service
monitoringService.recordValidationError({
schema: schema.constructor.name,
context,
errorCount: result.error.errors.length
})
}
return result
}
// Usage
const result = parseWithLogging(
userSchema,
requestBody,
'POST /api/users'
)---
Summary
This document covered:
- ✅ Default and custom error messages
- ✅ Error map customization (global and schema-specific)
- ✅ Internationalization patterns
- ✅ Error formatting for UI and API responses
- ✅ Error flattening for nested objects
- ✅ Safe parsing vs throwing
- ✅ Try-catch patterns for error handling
- ✅ Error recovery strategies
Next Steps:
- [Refinements](./refinements.md) - Custom validation logic
- [API Integration](./api-integration.md) - Use in Next.js routes
- [Common Schemas](./common-schemas.md) - Ready-to-use schemas
---
Last updated: 2025-11-23 | Zod v4.1.12
Zod Validation Patterns - Complete Skill Summary
Overview
Comprehensive Zod validation skill for Quetrex providing production-ready patterns for all input validation scenarios.
Statistics
- Total Files: 9 (including SKILL.md index)
- Total Lines: 8,018
- Total Code Examples: 351+
- Ready-to-Use Schemas: 20+
- Documentation Size: 171 KB
File Breakdown
1. SKILL.md (271 lines, 9 examples)
Entry point and quick start guide
- Purpose and scope overview
- Quick start examples
- Best practices
- Navigation to all other files
2. schema-patterns.md (1,414 lines, 78 examples)
Complete guide to ALL Zod schema types
- String validation (email, url, uuid, regex, etc.)
- Number validation (int, positive, range, etc.)
- Boolean, Date, Array, Object validation
- Enum, Literal, Union, Intersection
- Tuple, Record, Map, Set
- Optional, Nullable, Default values
- Branded types, Recursive schemas
- Discriminated unions
3. error-handling.md (977 lines, 32 examples)
Robust error management patterns
- Default and custom error messages
- Error map customization
- Internationalization (i18n)
- Error formatting for UI/API
- Error flattening
- Safe parsing patterns
- Try-catch best practices
- Error recovery strategies
4. refinements.md (943 lines, 37 examples)
Custom validation logic
- Basic and chained refinements
- Cross-field validation
- Conditional validation
- Business logic validation
- File upload validation
- Date range validation
- Uniqueness checks
- Complex validation patterns
5. transforms.md (776 lines, 43 examples)
Data transformation and normalization
- Basic transformations (trim, case conversion)
- Type coercion (string to number, etc.)
- Data normalization (phone, email, URL)
- Default value injection
- Data cleaning
- Computed fields
- Date/JSON/URL parsing
- Transform pipelines
6. async-validation.md (828 lines, 29 examples)
Asynchronous validation patterns
- Async refinements
- Database uniqueness checks
- Email/URL verification
- API validations
- Error and timeout handling
- Concurrent validations
- Caching strategies (TTL, LRU, Redis)
- Advanced patterns (debounce, batch, retry)
7. type-inference.md (863 lines, 38 examples)
TypeScript type extraction
- z.infer basics
- Input vs output types
- Type extraction from complex schemas
- Discriminated union inference
- Recursive type inference
- Branded types
- Generic schema types
- Utility types and advanced patterns
8. api-integration.md (938 lines, 26 examples)
Next.js integration patterns
- API route validation (GET, POST, PATCH, DELETE)
- Server Actions with FormData
- Middleware validation
- Request body, query params, path params
- Form data validation
- File upload validation
- Error response formatting
- Try-catch patterns
9. common-schemas.md (1,008 lines, 59 examples)
Ready-to-use validation library
- Email (basic, business, disposable check)
- Password (strength levels, confirmation)
- Phone (US, international)
- URL (basic, HTTPS, with path)
- UUID, CUID (basic and branded)
- Date (ISO, range, future/past, birthdate)
- Currency (USD, multi-currency, price range)
- Address (US, international)
- Credit Card (Luhn, CVV, expiry, full card)
- Username (basic, reserved check)
- Slug (URL-safe, auto-generate)
- Hex Color (6-char, 8-char with alpha, RGB)
- IP Address (v4, v6, both)
- JSON (string parsing, typed)
- File Upload (image, document, avatar)
- Pagination (basic, with sorting, cursor-based)
- Social Media (Twitter, Instagram, GitHub)
- Tax IDs (SSN, EIN)
- Timezone (IANA, UTC offset)
Reusable Schemas (20+)
Authentication & User Data
1. emailSchema - RFC 5322 compliant email 2. passwordSchema - Strong password with all requirements 3. passwordWithConfirmationSchema - Password + confirmation 4. usernameSchema - Alphanumeric with constraints 5. birthdateSchema - 18+ age verification
Contact Information
6. usPhoneSchema - US phone number normalization 7. internationalPhoneSchema - E.164 format 8. usAddressSchema - Complete US address 9. internationalAddressSchema - Global address
Identifiers
10. uuidSchema - UUID v4 validation 11. userIdSchema - Branded UUID for users 12. slugSchema - URL-safe slug 13. cuidSchema - CUID v2 validation
Web & URLs
14. urlSchema - URL with auto-HTTPS 15. secureUrlSchema - HTTPS only 16. hexColorSchema - #RRGGBB format 17. ipAddressSchema - IPv4/IPv6
Payment
18. creditCardNumberSchema - Luhn algorithm validation 19. cvvSchema - CVV validation 20. usdCurrencySchema - USD with 2 decimal places
Files & Media
21. imageFileSchema - Image upload (JPEG, PNG, WebP) 22. documentFileSchema - Document upload (PDF, Word) 23. avatarSchema - Avatar with dimension check
API & Data
24. paginationSchema - Page + limit 25. jsonStringSchema - JSON parsing 26. isoDateSchema - ISO date string to Date
Coverage Checklist
String Validation
- [x] Email (RFC 5322)
- [x] URL (with protocol normalization)
- [x] UUID v4
- [x] CUID v2
- [x] Regex patterns
- [x] Length constraints (min, max, exact)
- [x] String transformations (trim, lowercase, uppercase)
- [x] Starts/ends with
- [x] Contains/includes
- [x] DateTime (ISO 8601)
- [x] IP address (v4, v6)
Number Validation
- [x] Integer validation
- [x] Range constraints (min, max, gt, gte, lt, lte)
- [x] Positive/negative/nonnegative/nonpositive
- [x] Finite numbers
- [x] Safe integers
- [x] Multiple of
- [x] Currency (2 decimal places)
Complex Types
- [x] Arrays (with length constraints)
- [x] Objects (strict, strip, passthrough)
- [x] Enums (string and native)
- [x] Literals
- [x] Unions (OR logic)
- [x] Intersections (AND logic)
- [x] Tuples (fixed-length arrays)
- [x] Records (key-value pairs)
- [x] Maps and Sets
- [x] Discriminated unions
Advanced Features
- [x] Optional/nullable/nullish fields
- [x] Default values (static and computed)
- [x] Branded types (nominal typing)
- [x] Recursive schemas (trees, linked lists)
- [x] Type inference (z.infer)
- [x] Input vs output types
- [x] Generic schema types
Validation Patterns
- [x] Basic refinements
- [x] Chained refinements
- [x] Cross-field validation
- [x] Conditional validation
- [x] Business logic validation
- [x] File upload validation
- [x] Async validation (database, API)
- [x] Uniqueness checks
Transformations
- [x] Type coercion
- [x] Data normalization
- [x] Data cleaning
- [x] Computed fields
- [x] Date parsing
- [x] JSON parsing
- [x] URL parsing
- [x] Transform pipelines
Error Handling
- [x] Custom error messages
- [x] Error maps
- [x] Internationalization
- [x] Error formatting for UI
- [x] Error flattening
- [x] Safe parsing
- [x] Try-catch patterns
- [x] Error recovery
Next.js Integration
- [x] API routes (all methods)
- [x] Server Actions
- [x] Middleware
- [x] Query parameters
- [x] Path parameters
- [x] Form data
- [x] File uploads
- [x] Error responses
Usage Guide
1. Quick Start
import { z } from 'zod'
// Define schema
const userSchema = z.object({
email: z.string().email(),
age: z.number().int().positive()
})
// Validate data
const user = userSchema.parse(data)
// Safe parse
const result = userSchema.safeParse(data)
if (result.success) {
console.log(result.data)
}2. Use Common Schemas
import { emailSchema, passwordSchema } from '@/lib/schemas'
const registerSchema = z.object({
email: emailSchema,
password: passwordSchema
})3. API Route Integration
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
const result = schema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: result.error.format() },
{ status: 400 }
)
}
// Process validated data
return NextResponse.json({ success: true })
}4. Server Action
'use server'
export async function createUser(formData: FormData) {
const result = userSchema.safeParse({
name: formData.get('name'),
email: formData.get('email')
})
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors }
}
// Process
return { success: true }
}When to Use This Skill
Use for:
- API request validation
- Form submission validation
- Database input validation
- Configuration validation
- File upload validation
- External API response validation
Don't use for:
- Simple type checks (use TypeScript)
- Performance-critical paths
- Already validated data
Best Practices
1. Validate at boundaries - API routes, Server Actions, external data 2. Use safe parsing - Prefer safeParse() over parse() 3. Provide clear errors - Customize messages for users 4. Reuse schemas - Import from common-schemas.md 5. Type inference - Always use z.infer<typeof schema> 6. Test thoroughly - Edge cases, boundary values 7. Document complexity - JSDoc for business rules
Resources
- Zod Documentation: https://zod.dev/
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/
- Next.js Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
Navigation
Start with: 1. SKILL.md - Overview and quick start 2. common-schemas.md - Ready-to-use schemas (FASTEST WAY) 3. api-integration.md - Next.js integration
Deep dive: 4. schema-patterns.md - All schema types 5. error-handling.md - Better error messages 6. refinements.md - Custom validation 7. transforms.md - Data transformation 8. async-validation.md - Database/API checks 9. type-inference.md - TypeScript patterns
---
Last updated: 2025-11-23 | Zod v4.1.12
Refinements - Custom Validation Logic
This document covers all refinement patterns in Zod for implementing custom validation logic beyond built-in validators.
Table of Contents
- Basic Refinement
- Multiple Refinements
- Custom Error Messages
- Cross-Field Validation
- Conditional Validation
- Business Logic Validation
- File Upload Validation
- Date Range Validation
- Uniqueness Validation
- Complex Validation Patterns
---
Basic Refinement
Simple Refinement
import { z } from 'zod'
// Basic refinement with boolean return
const evenNumberSchema = z.number().refine(
val => val % 2 === 0,
{ message: 'Number must be even' }
)
// Without custom message (uses default)
const positiveSchema = z.number().refine(val => val > 0)
// Multiple conditions
const strongPasswordSchema = z.string().refine(
val => {
return val.length >= 8 &&
/[A-Z]/.test(val) &&
/[a-z]/.test(val) &&
/[0-9]/.test(val)
},
{ message: 'Password must be at least 8 characters with uppercase, lowercase, and numbers' }
)Refinement with Path
// Specify which field the error belongs to
const userSchema = z.object({
username: z.string(),
email: z.string()
}).refine(
data => data.username !== data.email,
{
message: 'Username and email cannot be the same',
path: ['username'] // Error will appear on username field
}
)Refinement with Function Message
const rangeSchema = z.number().refine(
val => val >= 0 && val <= 100,
val => ({
message: `Value ${val} is out of range (0-100)`
})
)---
Multiple Refinements
Chaining Refinements
const passwordSchema = z.string()
.refine(val => val.length >= 8, {
message: 'Password must be at least 8 characters'
})
.refine(val => /[A-Z]/.test(val), {
message: 'Password must contain at least one uppercase letter'
})
.refine(val => /[a-z]/.test(val), {
message: 'Password must contain at least one lowercase letter'
})
.refine(val => /[0-9]/.test(val), {
message: 'Password must contain at least one number'
})
.refine(val => /[^A-Za-z0-9]/.test(val), {
message: 'Password must contain at least one special character'
})
// All refinements are checked, all errors returnedOrdered Refinements
// Refinements run in order
const schema = z.string()
.refine(val => val.length > 0, 'Required')
.refine(val => val.length <= 100, 'Too long')
.refine(val => /^[a-zA-Z]+$/.test(val), 'Only letters allowed')
// If first fails, subsequent refinements may not runCombined Refinements
const userSchema = z.object({
email: z.string().email(),
username: z.string().min(3).max(20)
})
// First refinement
.refine(
data => data.username !== 'admin',
{ message: 'Username "admin" is reserved', path: ['username'] }
)
// Second refinement
.refine(
data => !data.email.includes(data.username),
{ message: 'Email should not contain username', path: ['email'] }
)---
Custom Error Messages
Dynamic Error Messages
const ageSchema = z.number().refine(
val => val >= 18,
val => ({ message: `You are ${val} years old. Must be at least 18.` })
)
const usernameSchema = z.string().refine(
val => val.length >= 3,
val => ({ message: `Username "${val}" is too short (minimum 3 characters)` })
)Contextual Error Messages
function createMinLengthSchema(minLength: number, fieldName: string) {
return z.string().refine(
val => val.length >= minLength,
{ message: `${fieldName} must be at least ${minLength} characters` }
)
}
const usernameSchema = createMinLengthSchema(3, 'Username')
const bioSchema = createMinLengthSchema(10, 'Bio')Multiple Error Paths
const schema = z.object({
password: z.string(),
confirmPassword: z.string()
}).refine(
data => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ['confirmPassword'] // Error shown on confirmPassword field
}
)
// Can also show error on multiple fields
const multiPathSchema = z.object({
startDate: z.date(),
endDate: z.date()
}).refine(
data => data.endDate > data.startDate,
{
message: 'End date must be after start date',
path: ['endDate'] // Could also be ['startDate', 'endDate']
}
)---
Cross-Field Validation
Password Confirmation
const signupSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine(
data => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ['confirmPassword']
}
)Date Range Validation
const eventSchema = z.object({
startDate: z.date(),
endDate: z.date()
}).refine(
data => data.endDate > data.startDate,
{
message: 'End date must be after start date',
path: ['endDate']
}
)Dependent Fields
const addressSchema = z.object({
country: z.string(),
state: z.string().optional(),
province: z.string().optional()
}).refine(
data => {
if (data.country === 'US') {
return !!data.state
}
if (data.country === 'CA') {
return !!data.province
}
return true
},
{
message: 'State is required for US addresses',
path: ['state']
}
)Mutual Exclusivity
const contactSchema = z.object({
email: z.string().email().optional(),
phone: z.string().optional()
}).refine(
data => data.email || data.phone,
{
message: 'Either email or phone is required',
path: ['email']
}
)
// Exactly one must be provided
const exclusiveSchema = z.object({
email: z.string().email().optional(),
phone: z.string().optional()
}).refine(
data => !!(data.email) !== !!(data.phone), // XOR
{
message: 'Provide either email or phone, not both',
path: ['email']
}
)Field Comparison
const priceSchema = z.object({
minPrice: z.number(),
maxPrice: z.number()
}).refine(
data => data.maxPrice >= data.minPrice,
{
message: 'Maximum price must be greater than or equal to minimum price',
path: ['maxPrice']
}
)
const bidSchema = z.object({
currentBid: z.number(),
yourBid: z.number()
}).refine(
data => data.yourBid > data.currentBid,
{
message: 'Your bid must be higher than the current bid',
path: ['yourBid']
}
)---
Conditional Validation
If-Then Validation
const shippingSchema = z.object({
requiresShipping: z.boolean(),
shippingAddress: z.string().optional()
}).refine(
data => {
if (data.requiresShipping) {
return !!data.shippingAddress && data.shippingAddress.length > 0
}
return true
},
{
message: 'Shipping address is required when shipping is needed',
path: ['shippingAddress']
}
)Role-Based Validation
const userSchema = z.object({
role: z.enum(['admin', 'user', 'guest']),
permissions: z.array(z.string()).optional(),
department: z.string().optional()
}).refine(
data => {
if (data.role === 'admin') {
return !!data.permissions && data.permissions.length > 0
}
return true
},
{
message: 'Admins must have at least one permission',
path: ['permissions']
}
).refine(
data => {
if (data.role === 'user') {
return !!data.department
}
return true
},
{
message: 'Users must belong to a department',
path: ['department']
}
)Payment Method Validation
const paymentSchema = z.object({
method: z.enum(['card', 'paypal', 'bank_transfer']),
cardNumber: z.string().optional(),
paypalEmail: z.string().email().optional(),
accountNumber: z.string().optional()
}).refine(
data => {
if (data.method === 'card') return !!data.cardNumber
if (data.method === 'paypal') return !!data.paypalEmail
if (data.method === 'bank_transfer') return !!data.accountNumber
return false
},
data => ({
message: `${data.method} details are required`,
path: [
data.method === 'card' ? 'cardNumber' :
data.method === 'paypal' ? 'paypalEmail' :
'accountNumber'
]
})
)Conditional Required Fields
const employmentSchema = z.object({
employed: z.boolean(),
employer: z.string().optional(),
position: z.string().optional(),
unemployed: z.boolean(),
unemploymentReason: z.string().optional()
}).refine(
data => {
if (data.employed) {
return !!data.employer && !!data.position
}
if (data.unemployed) {
return !!data.unemploymentReason
}
return true
},
data => {
if (data.employed && !data.employer) {
return { message: 'Employer is required', path: ['employer'] }
}
if (data.employed && !data.position) {
return { message: 'Position is required', path: ['position'] }
}
if (data.unemployed && !data.unemploymentReason) {
return { message: 'Reason is required', path: ['unemploymentReason'] }
}
return { message: 'Invalid state' }
}
)---
Business Logic Validation
Credit Card Luhn Algorithm
function luhnCheck(cardNumber: string): boolean {
const digits = cardNumber.replace(/\D/g, '')
let sum = 0
let isEven = false
for (let i = digits.length - 1; i >= 0; i--) {
let digit = parseInt(digits[i], 10)
if (isEven) {
digit *= 2
if (digit > 9) digit -= 9
}
sum += digit
isEven = !isEven
}
return sum % 10 === 0
}
const creditCardSchema = z.string()
.regex(/^\d{13,19}$/, 'Invalid card number format')
.refine(luhnCheck, { message: 'Invalid credit card number' })Tax ID Validation
// US SSN validation (XXX-XX-XXXX)
const ssnSchema = z.string()
.regex(/^\d{3}-\d{2}-\d{4}$/, 'SSN must be in format XXX-XX-XXXX')
.refine(
val => {
const [area, group, serial] = val.split('-')
return area !== '000' &&
area !== '666' &&
parseInt(area) < 900 &&
group !== '00' &&
serial !== '0000'
},
{ message: 'Invalid SSN number' }
)
// EIN validation (XX-XXXXXXX)
const einSchema = z.string()
.regex(/^\d{2}-\d{7}$/, 'EIN must be in format XX-XXXXXXX')
.refine(
val => {
const prefix = parseInt(val.split('-')[0])
return prefix >= 1 && prefix <= 99
},
{ message: 'Invalid EIN number' }
)Business Hours Validation
const appointmentSchema = z.object({
dateTime: z.date()
}).refine(
data => {
const hour = data.dateTime.getHours()
const day = data.dateTime.getDay()
// Monday-Friday, 9 AM - 5 PM
return day >= 1 && day <= 5 && hour >= 9 && hour < 17
},
{
message: 'Appointments must be scheduled during business hours (Mon-Fri, 9 AM - 5 PM)',
path: ['dateTime']
}
)Age Restriction
function calculateAge(birthdate: Date): number {
const today = new Date()
let age = today.getFullYear() - birthdate.getFullYear()
const monthDiff = today.getMonth() - birthdate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthdate.getDate())) {
age--
}
return age
}
const userSchema = z.object({
birthdate: z.date()
}).refine(
data => calculateAge(data.birthdate) >= 18,
{
message: 'You must be at least 18 years old',
path: ['birthdate']
}
)
// Age range
const seniorDiscountSchema = z.object({
birthdate: z.date()
}).refine(
data => {
const age = calculateAge(data.birthdate)
return age >= 65
},
{
message: 'Senior discount available for ages 65+',
path: ['birthdate']
}
)Inventory Check
const orderItemSchema = z.object({
productId: z.string(),
quantity: z.number().int().positive()
}).refine(
async data => {
const product = await getProduct(data.productId)
return product.stock >= data.quantity
},
data => ({
message: `Only ${data.quantity} units available`,
path: ['quantity']
})
)---
File Upload Validation
File Size Validation
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const fileSchema = z.instanceof(File).refine(
file => file.size <= MAX_FILE_SIZE,
{ message: 'File size must be less than 5MB' }
)File Type Validation
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const imageSchema = z.instanceof(File).refine(
file => ALLOWED_IMAGE_TYPES.includes(file.type),
{ message: 'Only JPEG, PNG, and WebP images are allowed' }
)
// Multiple checks
const uploadSchema = z.instanceof(File)
.refine(
file => file.size <= MAX_FILE_SIZE,
{ message: 'File must be less than 5MB' }
)
.refine(
file => ALLOWED_IMAGE_TYPES.includes(file.type),
{ message: 'Only JPEG, PNG, and WebP images are allowed' }
)Image Dimensions Validation
async function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve({ width: img.width, height: img.height })
img.onerror = reject
img.src = URL.createObjectURL(file)
})
}
const profilePictureSchema = z.instanceof(File)
.refine(
file => ALLOWED_IMAGE_TYPES.includes(file.type),
{ message: 'Invalid file type' }
)
.refine(
async file => {
const { width, height } = await getImageDimensions(file)
return width >= 200 && height >= 200
},
{ message: 'Image must be at least 200x200 pixels' }
)
.refine(
async file => {
const { width, height } = await getImageDimensions(file)
return width <= 4000 && height <= 4000
},
{ message: 'Image must be at most 4000x4000 pixels' }
)File Name Validation
const documentSchema = z.instanceof(File).refine(
file => {
const extension = file.name.split('.').pop()?.toLowerCase()
return ['pdf', 'doc', 'docx'].includes(extension || '')
},
{ message: 'Only PDF and Word documents are allowed' }
).refine(
file => !file.name.includes('..'),
{ message: 'Invalid file name' }
)Multiple Files Validation
const multipleFilesSchema = z.array(z.instanceof(File))
.min(1, 'At least one file is required')
.max(10, 'Maximum 10 files allowed')
.refine(
files => files.every(file => file.size <= MAX_FILE_SIZE),
{ message: 'Each file must be less than 5MB' }
)
.refine(
files => files.every(file => ALLOWED_IMAGE_TYPES.includes(file.type)),
{ message: 'All files must be images (JPEG, PNG, or WebP)' }
)
.refine(
files => {
const totalSize = files.reduce((sum, file) => sum + file.size, 0)
return totalSize <= 50 * 1024 * 1024 // 50MB total
},
{ message: 'Total upload size must be less than 50MB' }
)---
Date Range Validation
Event Scheduling
const eventSchema = z.object({
startDate: z.date(),
endDate: z.date()
})
.refine(
data => data.endDate > data.startDate,
{
message: 'End date must be after start date',
path: ['endDate']
}
)
.refine(
data => {
const duration = data.endDate.getTime() - data.startDate.getTime()
const maxDuration = 7 * 24 * 60 * 60 * 1000 // 7 days
return duration <= maxDuration
},
{
message: 'Event cannot be longer than 7 days',
path: ['endDate']
}
)Booking Window
const bookingSchema = z.object({
checkIn: z.date(),
checkOut: z.date()
})
.refine(
data => data.checkOut > data.checkIn,
{
message: 'Check-out must be after check-in',
path: ['checkOut']
}
)
.refine(
data => {
const now = new Date()
const minAdvance = new Date(now.getTime() + 24 * 60 * 60 * 1000) // 24 hours
return data.checkIn >= minAdvance
},
{
message: 'Bookings must be made at least 24 hours in advance',
path: ['checkIn']
}
)
.refine(
data => {
const nights = Math.ceil(
(data.checkOut.getTime() - data.checkIn.getTime()) / (1000 * 60 * 60 * 24)
)
return nights >= 1 && nights <= 30
},
{
message: 'Booking must be between 1 and 30 nights',
path: ['checkOut']
}
)Expiration Date
const cardSchema = z.object({
expiryMonth: z.number().min(1).max(12),
expiryYear: z.number()
}).refine(
data => {
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() + 1
if (data.expiryYear < currentYear) return false
if (data.expiryYear === currentYear && data.expiryMonth < currentMonth) return false
return true
},
{
message: 'Card has expired',
path: ['expiryMonth']
}
)---
Uniqueness Validation
Database Uniqueness (Async)
// Check if email is unique
const emailSchema = z.string()
.email()
.refine(
async email => {
const existing = await db.user.findUnique({ where: { email } })
return !existing
},
{ message: 'Email is already registered' }
)
// Check if username is unique
const usernameSchema = z.string()
.min(3)
.max(20)
.regex(/^[a-zA-Z0-9_-]+$/)
.refine(
async username => {
const existing = await db.user.findUnique({ where: { username } })
return !existing
},
{ message: 'Username is already taken' }
)Array Uniqueness
// Unique emails in array
const emailListSchema = z.array(z.string().email()).refine(
emails => new Set(emails).size === emails.length,
{ message: 'Emails must be unique' }
)
// Unique IDs
const idListSchema = z.array(z.string().uuid()).refine(
ids => new Set(ids).size === ids.length,
{ message: 'IDs must be unique' }
)
// Complex object uniqueness
const usersSchema = z.array(
z.object({
id: z.string(),
email: z.string().email()
})
).refine(
users => {
const emails = users.map(u => u.email)
return new Set(emails).size === emails.length
},
{ message: 'User emails must be unique' }
)Slug Uniqueness
const slugSchema = z.string()
.regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens')
.refine(
async slug => {
const existing = await db.post.findUnique({ where: { slug } })
return !existing
},
{ message: 'This slug is already in use' }
)---
Complex Validation Patterns
Nested Object Validation
const orderSchema = z.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().positive()
})
),
total: z.number()
}).refine(
data => {
// Validate that total matches sum of item prices
const calculatedTotal = data.items.reduce((sum, item) => {
// This would normally fetch price from database
return sum + (item.quantity * 10) // Example calculation
}, 0)
return Math.abs(data.total - calculatedTotal) < 0.01 // Account for floating point
},
{
message: 'Total does not match item prices',
path: ['total']
}
)Multi-Step Validation
const registrationSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
termsAccepted: z.boolean()
})
// Step 1: Password confirmation
.refine(
data => data.password === data.confirmPassword,
{ message: "Passwords don't match", path: ['confirmPassword'] }
)
// Step 2: Terms must be accepted
.refine(
data => data.termsAccepted === true,
{ message: 'You must accept the terms', path: ['termsAccepted'] }
)
// Step 3: Check email uniqueness (async)
.refine(
async data => {
const existing = await db.user.findUnique({ where: { email: data.email } })
return !existing
},
{ message: 'Email already registered', path: ['email'] }
)Polymorphic Validation
const mediaSchema = z.object({
type: z.enum(['image', 'video', 'audio']),
url: z.string().url(),
duration: z.number().optional(),
width: z.number().optional(),
height: z.number().optional()
}).refine(
data => {
if (data.type === 'video' || data.type === 'audio') {
return !!data.duration
}
if (data.type === 'image') {
return !!data.width && !!data.height
}
return true
},
data => {
if (data.type === 'video' || data.type === 'audio') {
return { message: 'Duration required for video/audio', path: ['duration'] }
}
return { message: 'Dimensions required for images', path: ['width'] }
}
)---
Summary
This document covered:
- ✅ Basic and chained refinements
- ✅ Custom error messages with dynamic content
- ✅ Cross-field validation (password confirmation, date ranges)
- ✅ Conditional validation (if-then logic)
- ✅ Business logic (Luhn, SSN, age calculation)
- ✅ File upload validation (size, type, dimensions)
- ✅ Date range and booking validation
- ✅ Uniqueness checks (database, array)
- ✅ Complex nested and multi-step validation
Next Steps:
- [Transforms](./transforms.md) - Data transformation
- [Async Validation](./async-validation.md) - Database/API checks
- [Common Schemas](./common-schemas.md) - Ready-to-use patterns
---
Last updated: 2025-11-23 | Zod v4.1.12
Related skills
FAQ
When should I use Zod?
For API requests, form submissions, database input, config, file uploads, and external API responses at boundaries.
parse or safeParse?
Prefer safeParse over parse for better error handling.