What Is Zod and Why Does It Matter?
Zod is a TypeScript-first schema validation library with over 20 million weekly npm downloads. It solves one of the most frustrating problems in TypeScript: compile-time types give you no guarantee that runtime data actually matches them.
When you call fetch("/api/users") and cast the result as User[], TypeScript believes you — but the runtime might receive a completely different shape. Zod validates the shape at runtime and throws a detailed, actionable error if it does not match:
import { z } from "zod";
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
email: z.string().email(),
active: z.boolean(),
});
type User = z.infer<typeof UserSchema>;
// type User = { id: number; name: string; email: string; active: boolean }
// You get the TypeScript type automatically from the schema — no duplication
// At runtime:
const raw = await fetch("/api/users/1").then(r => r.json());
const user = UserSchema.parse(raw);
// user is now fully typed AND runtime-validatedGenerating a Zod Schema from JSON
Instead of writing schemas manually, paste your API response JSON and get the schema auto-generated:
Input JSON:
{
"id": 1,
"name": "Ravi Kumar",
"active": true,
"score": 98.5,
"address": { "city": "Surat", "country": "IN" },
"tags": ["developer", "typescript"],
"createdAt": "2025-06-01T10:30:00Z"
}Generated Zod schema:
import { z } from "zod";
export const AddressSchema = z.object({
city: z.string(),
country: z.string(),
});
export const UserSchema = z.object({
id: z.number().int(),
name: z.string(),
active: z.boolean(),
score: z.number(),
address: AddressSchema,
tags: z.array(z.string()),
createdAt: z.string(),
});
export type User = z.infer<typeof UserSchema>;
export type Address = z.infer<typeof AddressSchema>;JSON to Zod Type Mapping
| JSON type | Zod validator | TypeScript type |
|---|---|---|
| string | z.string() | string |
| integer number | z.number().int() | number |
| float number | z.number() | number |
| boolean | z.boolean() | boolean |
| null | z.null() | null |
| object | z.object({...}) | Named interface |
| array of strings | z.array(z.string()) | string[] |
| empty array | z.array(z.unknown()) | unknown[] |
| mixed types | z.union([...]) | Union type |
Adding Real-World Constraints
The generator creates a baseline schema. Add refinements for your actual business rules:
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1, "Name cannot be empty").max(100, "Name too long"),
email: z.string().email("Invalid email address"),
age: z.number().int().min(0).max(150).optional(),
url: z.string().url().optional(),
role: z.enum(["admin", "editor", "viewer"]),
tags: z.array(z.string()).min(1, "At least one tag required"),
score: z.number().min(0).max(100),
phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number").optional(),
});Optional vs Nullable vs Nullish
const schema = z.object({
name: z.string(), // required, cannot be null or undefined
middleName: z.string().optional(), // may be absent (undefined); field can be omitted
bio: z.string().nullable(), // present but may be null
avatar: z.string().nullish(), // may be null or undefined (both)
count: z.number().default(0), // optional with default value
});
// Type inference:
// name: string
// middleName: string | undefined
// bio: string | null
// avatar: string | null | undefined
// count: numberparse() vs safeParse()
// parse() — throws ZodError with detailed issues if validation fails
try {
const user = UserSchema.parse(rawData);
// user is fully typed and validated
} catch (e) {
if (e instanceof z.ZodError) {
console.error("Validation failed:", e.issues);
// Each issue: { path: ["email"], message: "Invalid email", code: "invalid_string" }
}
}
// safeParse() — never throws; returns a discriminated union
const result = UserSchema.safeParse(rawData);
if (!result.success) {
console.error("Errors:", result.error.issues);
return null;
}
const user = result.data; // typed as UserUse safeParse for API handlers and form validation. Use parse in data pipelines where you want to throw on invalid data.
Integration with Next.js API Route (App Router)
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["admin", "viewer"]).default("viewer"),
});
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => null);
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ errors: result.error.issues.map(i => ({ field: i.path.join("."), message: i.message })) },
{ status: 400 }
);
}
const { name, email, role } = result.data; // fully typed
// ... create user
return NextResponse.json({ created: true });
}Integration with React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Please enter a valid email"),
age: z.number().int().min(18, "Must be 18 or older"),
});
type FormData = z.infer<typeof formSchema>;
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(formSchema),
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
{/* ... */}
</form>
);
}Validating Environment Variables
// src/env.ts — validate at startup, not silently at runtime
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 chars"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(["development", "test", "production"]),
});
export const env = envSchema.parse(process.env);
// Throws with clear messages on startup if any env var is missing or wrong
// e.g., "Invalid url" for DATABASE_URL, not a cryptic runtime error laterZod vs TypeScript Interfaces vs io-ts
| Feature | TypeScript interfaces | Zod | io-ts |
|---|---|---|---|
| Compile-time types | Yes | Yes (via z.infer) | Yes |
| Runtime validation | No | Yes | Yes |
| Error messages | N/A | Detailed, structured | Verbose |
| Learning curve | Low | Low | High (fp-ts) |
| Bundle size | Zero (erased) | ~13KB gzip | ~12KB + fp-ts |
| Transform/coerce | No | Yes (z.coerce, .transform) | Limited |
Use JSONKit's JSON to Zod Schema tool to generate your baseline schema from any JSON response — then add constraints and refinements for your business rules.