Why Zod?
TypeScript gives you type safety at compile time, but types don't exist at runtime. Once data arrives from an API, a form, or an environment variable, the compiler can't help you. These examples show the problems Zod was built to solve.
TypeScript Can't Protect You at Runtime
TypeScript types only exist at compile time. They are erased when your code runs, which means JSON.parse() always returns any and the compiler will happily accept whatever shape arrives from the network.
// TypeScript trusts you — but can't verify runtime data
const response = await fetch('/api/user');
const user = await response.json(); // type: any ← no safety here
// The compiler is happy with this. But if the API returns
// { id: "123", name: null } instead of { id: 123, name: "Alice" }
// ...this crashes at runtime with no warning:
const formatted = user.id.toFixed(2);
// ^^^^^^^^ 💥 TypeError: toFixed is not a function
// TypeScript gave you a false sense of security.import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
const response = await fetch('/api/user');
// Throws immediately with a clear error if the shape is wrong:
// "Expected number, received string" at path: id
const user = UserSchema.parse(await response.json());
// Now user.id is guaranteed to be a number ✅
const formatted = user.id.toFixed(2);Manual Type Guards Are Verbose and Fragile
Writing type guards by hand is tedious, hard to read, and easy to get wrong. Miss one field check and you have a type lie that the compiler will never catch.
interface User {
id: number;
name: string;
email: string;
}
// ~15 lines to check 3 fields — and still easy to miss one
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as any).id === 'number' &&
'name' in value &&
typeof (value as any).name === 'string' &&
'email' in value &&
typeof (value as any).email === 'string'
// Forgot to check email format? Compiler won't tell you.
);
}import { z } from 'zod';
// One schema replaces the type + guard entirely
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.email(), // format validated too
});
// Type-safe, runtime-verified, zero boilerplate
const result = UserSchema.safeParse(data);
if (result.success) {
result.data;
// type: { id: number; name: string; email: string } ✅
}Types and Validation Logic Drift Apart
When a TypeScript interface and its runtime validation live in separate places, they will fall out of sync. Add a field to the interface, forget to update the validator, and the compiler won't say a word. Invalid data passes through quietly.
// types.ts — the interface lives here
interface User {
id: number;
name: string;
role: 'admin' | 'user' | 'guest'; // ← added 3 months later
}
// validation.ts — the validator lives here, separately
function validateUser(data: unknown): User {
if (typeof data !== 'object' || data === null) {
throw new Error('Not an object');
}
// 'role' was never added here.
// Invalid roles ("superadmin") silently pass through as User.
return data as User; // a type cast, not a guarantee
}import { z } from 'zod';
// Schema is the single source of truth — one place to update
export const UserSchema = z.object({
id: z.number(),
name: z.string(),
role: z.enum(['admin', 'user', 'guest']),
});
// Type is always derived from the schema — can never drift
export type User = z.infer<typeof UserSchema>;
// { id: number; name: string; role: "admin" | "user" | "guest" }
// Add 'superadmin' to the enum → the type updates automatically.
// No separate interface to keep in sync. ✅