Zod Schema Validation: Runtime Type Safety for TypeScript APIs
TypeScript types disappear at runtime. A user can send any JSON payload — Zod validates it at runtime and infers TypeScript types from the same schema.
Why Zod
// Without Zod — you're trusting the user
app.post('/users', (req, res) => {
const { name, email } = req.body; // Could be anything
await db.user.create({ data: { name, email } }); // SQL injection risk
});
// With Zod — validated and typed
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
app.post('/users', (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error.flatten());
await db.user.create({ data: result.data }); // result.data is typed
});
Core Schemas
import { z } from 'zod';
// Primitives
z.string() // any string
z.string().email() // valid email
z.string().url() // valid URL
z.string().uuid() // valid UUID
z.number().int() // integer
z.number().min(0) // non-negative
z.boolean()
z.date()
// Objects
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.coerce.date(), // converts string to Date
});
// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
Nested Validation
const OrderSchema = z.object({
id: z.string(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})).min(1, 'Order must have at least one item'),
shipping: z.object({
address: z.string(),
city: z.string(),
country: z.string().length(2), // ISO country code
}),
discount: z.number().min(0).max(100).optional(),
});
Custom Validators
const PasswordSchema = z.string()
.min(8)
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[0-9]/, 'Must contain number')
.regex(/[^A-Za-z0-9]/, 'Must contain special character');
const DateRangeSchema = z.object({
start: z.coerce.date(),
end: z.coerce.date(),
}).refine(data => data.end > data.start, {
message: 'End date must be after start date',
path: ['end'],
});
Transforming Data
const SlugSchema = z.string()
.transform(s => s.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''));
const TagsSchema = z.union([
z.string().transform(s => s.split(',').map(t => t.trim())),
z.array(z.string()),
]);
Error Formatting
const result = UserSchema.safeParse(input);
if (!result.success) {
// Flat format for simple forms
const { fieldErrors } = result.error.flatten();
// { email: ['Invalid email'], name: ['Required'] }
// Full format with paths
const issues = result.error.issues;
// [{ path: ['email'], message: 'Invalid email', code: 'invalid_string' }]
}
React Hook Form Integration
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(CreateUserSchema),
});
// Validation runs on submit — errors populated automatically
Zod ships pre-integrated in the AI SaaS Starter Kit — all API routes validated, forms use zodResolver, shared types between client/server. $99 at whoffagents.com.
Top comments (0)