Zod: Runtime Type Validation That Actually Catches Production Bugs
TypeScript types disappear at runtime. Zod bridges the gap — it validates data at the boundaries of your system where TypeScript can't.
The Problem TypeScript Can't Solve
// TypeScript trusts you here — but what if the API returns something unexpected?
const user: User = await fetch('/api/user').then(r => r.json());
// user.age is typed as number, but the API might return '25' (string)
// TypeScript has no idea. Zod catches this.
Basic Schema Definition
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user', 'moderator']),
createdAt: z.string().datetime(),
});
// Derive TypeScript type from schema — single source of truth
type User = z.infer<typeof UserSchema>;
Parsing vs Safe Parsing
// parse() throws on invalid data
const user = UserSchema.parse(apiResponse); // throws ZodError if invalid
// safeParse() returns a result object — better for error handling
const result = UserSchema.safeParse(apiResponse);
if (!result.success) {
console.error('Validation failed:', result.error.flatten());
return;
}
const user = result.data; // fully typed User
API Request Validation
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5).default([]),
published: z.boolean().default(false),
});
// Express route with Zod validation
app.post('/api/posts', async (req, res) => {
const result = CreatePostSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
const post = await db.posts.create({ data: result.data });
res.json(post);
});
Transformations
Zod can coerce and transform data during parsing:
const QuerySchema = z.object({
// Coerce string '10' to number 10 (URL query params are always strings)
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
// Transform 'true'/'false' strings to booleans
published: z.enum(['true', 'false']).transform(v => v === 'true').optional(),
// Trim and lowercase email
email: z.string().transform(s => s.trim().toLowerCase()).pipe(z.string().email()),
});
Nested and Discriminated Unions
const NotificationSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), to: z.string().email(), subject: z.string() }),
z.object({ type: z.literal('sms'), phone: z.string(), message: z.string() }),
z.object({ type: z.literal('push'), deviceToken: z.string(), title: z.string() }),
]);
// TypeScript narrows the type correctly based on 'type' field
function send(notification: z.infer<typeof NotificationSchema>) {
if (notification.type === 'email') {
// notification.to and notification.subject are available here
}
}
Zod + tRPC + Prisma
The killer stack: Zod validates inputs, tRPC types the API, Prisma types the DB. Every layer is validated and typed end-to-end.
This full stack — Zod, tRPC, Prisma, NextAuth, and Stripe — comes pre-wired in the AI SaaS Starter Kit. Stop plumbing the same stack from scratch.
Top comments (0)