React - Types and Validation with Zod & TypeScript
Hi! Now that we know how to create forms, let's talk about data validation
The Problem with Validation
TypeScript, it's good but
TypeScript protects you during development, but:
// ✅ TypeScript validates this at build time
interface User {
name: string
age: number
email: string
}
function processUser(user: User) {
// TypeScript knows that user.name is a string
console.log(user.name.toUpperCase())
}
// ❌ But this can happen at runtime!
const userFromAPI = JSON.parse(response) // any 😱
processUser(userFromAPI) // BOOM if userFromAPI doesn't have the right structure!
The Dangers of Runtime
- 🌐 External APIs: can return anything
- 📝 User forms: unreliable data
- 🏪 LocalStorage/SessionStorage: can be corrupted
- 🔗 URL parameters: can be manipulated client-side
The Solution? Runtime Validation
We need to validate the data at runtime AND have the security of TypeScript types!
Zod - The Holy Grail! ⚡
Installation
npm install zod
Zod's Philosophy
- Schema-first: you define the schema once
- Type inference: TypeScript types generated automatically
- Runtime validation: validation at runtime
- Developer-friendly: clear error messages
First Example
import { z } from 'zod'
// 🔥 Define the schema once
const UserSchema = z.object({
name: z.string().min(1, 'Name required'),
age: z.number().min(0).max(120),
email: z.string().email('Invalid email'),
})
// ✅ TypeScript type generated automatically!
type User = z.infer<typeof UserSchema>
// type User = { name: string; age: number; email: string }
// 🛡️ Runtime validation
function createUser(data: unknown): User {
// This can throw a ZodError if invalid
return UserSchema.parse(data)
}
// 🚀 Usage
try {
const user = createUser({
name: "Andy",
age: 25,
email: "andy@example.com"
})
console.log(user.name) // TypeScript knows it's a string!
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues) // Error details
}
}
API Data Validation
Classic Problem
// ❌ Dangerous code that we see everywhere
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`)
const user = await response.json() // any - DANGER!
// What happens if the API returns something else?
return user.name.toUpperCase() // CAN CRASH!
}
Zod Solution
import { z } from 'zod'
// Validation schema
const UserApiSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
avatar: z.string().url().optional(),
createdAt: z.string().datetime(),
settings: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean()
})
})
type UserApi = z.infer<typeof UserApiSchema>
async function getUser(id: string): Promise<UserApi> {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('API Error')
const data = await response.json()
// 🛡️ API data validation
const user = UserApiSchema.parse(data)
return user // 100% safe now!
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Invalid API response:', error.issues)
}
throw error
}
}
// 🚀 Safe usage
const user = await getUser('123')
console.log(user.name.toUpperCase()) // ✅ Safe!
console.log(user.settings.theme) // ✅ Perfect TypeScript autocomplete
Advanced Form Validation
Complex Schemas
const ContactFormSchema = z.object({
// Advanced string validation
name: z.string()
.min(1, 'Name required')
.min(2, 'At least 2 characters')
.max(50, 'Max 50 characters')
.regex(/^[a-zA-ZÀ-ÿ\s]+$/, 'Invalid characters'),
// Email with domain whitelist
email: z.string()
.email('Invalid email format')
.refine(
email => ['gmail.com', 'outlook.com', 'company.com'].includes(email.split('@')[1]),
'Unauthorized email domain'
),
// French phone number
phone: z.string()
.regex(/^(?:(?:\+33|0)[1-9](?:[0-9]{8}))$/, 'Invalid French number')
.optional(),
// Age with business validation
age: z.number()
.min(16, 'You must be at least 16 years old')
.max(100, 'Invalid age'),
// Password with security criteria
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'At least one uppercase letter')
.regex(/[a-z]/, 'At least one lowercase letter')
.regex(/[0-9]/, 'At least one digit')
.regex(/[^A-Za-z0-9]/, 'At least one special character'),
// Password confirmation
confirmPassword: z.string(),
// Terms acceptance
acceptTerms: z.boolean().refine(val => val === true, 'Terms required'),
// Array of hobbies with validation
hobbies: z.array(
z.object({
name: z.string().min(1),
level: z.enum(['beginner', 'intermediate', 'expert'])
})
).min(1, 'At least one hobby').max(5, 'Max 5 hobbies')
})
// Cross-field validation
.refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'] // Error on the confirmPassword field
})
type ContactFormData = z.infer<typeof ContactFormSchema>
API Routes Validation (Next.js)
Server-Side Validation
// app/api/users/route.ts
import { z } from 'zod'
import { NextRequest, NextResponse from 'next/server'
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().min(16).max(100)
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// 🛡️ Validation of received data
const userData = CreateUserSchema.parse(body)
// Validated data, we can use it safely
const user = await createUserInDatabase(userData)
return NextResponse.json({ success: true, user })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation failed',
details: error.issues
},
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
Advanced Techniques
Conditional Schemas
const UserSchema = z.object({
type: z.enum(['admin', 'user']),
name: z.string(),
email: z.string().email()
})
.and(
z.discriminatedUnion('type', [
z.object({
type: z.literal('admin'),
permissions: z.array(z.string()).min(1)
}),
z.object({
type: z.literal('user'),
department: z.string()
})
])
)
Data Transformation
const DateSchema = z.string()
.datetime()
.transform(str => new Date(str))
const PriceSchema = z.string()
.regex(/^\d+(\.\d{2})?$/)
.transform(str => parseFloat(str))
const ProductSchema = z.object({
name: z.string(),
price: PriceSchema, // string → number
createdAt: DateSchema // string → Date
})