Parse, Don't Assert
Type assertions (as SomeType) are a promise to the compiler that you can't keep at runtime. Zod takes the opposite approach: define the schema once, parse the input, and get a properly typed value back β or a structured error. The schema is the type. No duplication, no drift.
- You're casting
res.json() as SomeApiTypeand hoping the API matches your type definition - Your environment variable parsing is a series of
process.env.X as stringassertions - You write a TypeScript interface AND a Yup schema AND keep them manually in sync
- A form submits with the right types in dev but blows up in production with different data
Zod eliminates the gap between βwhat TypeScript thinks this isβ and βwhat it actually is at runtimeβ β especially at API boundaries.
The Core Pattern
import { z } from 'zod';
// 1. Define the schema
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.string().datetime(),
});
// 2. Infer the TypeScript type β no duplication
type User = z.infer<typeof UserSchema>;
// 3. Parse at the boundary β throws ZodError if invalid
const user = UserSchema.parse(await res.json());
// user is fully typed as User β TS trusts it because Zod checked it
// 4. Safe parse β returns { success: true, data } | { success: false, error }
const result = UserSchema.safeParse(rawInput);
if (result.success) {
console.log(result.data.email); // typed
} else {
console.error(result.error.flatten()); // structured errors
}
flowchart LR
input["Raw Input
(unknown)"] --> schema["Zod Schema
.parse()"]
schema -->|valid| typed["Typed Value
User"]
schema -->|invalid| error["ZodError
.flatten()"]
style typed fill:#16a34a,color:#fff,stroke:#15803d
style error fill:#dc2626,color:#fff,stroke:#b91c1c
style schema fill:#0078D4,color:#fff,stroke:#005a9e
Environment Variables
Zod's most universally useful application: validated, typed env variables that fail at startup instead of randomly in production.
// env.ts β parse once at startup
import { z } from 'zod';
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
REDIS_URL: z.string().optional(),
PORT: z.coerce.number().default(3000), // coerce converts "3000" string to number
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});
export const env = EnvSchema.parse(process.env);
// Throws at startup with a clear error if any required var is missing or malformed
// env.PORT is typed as number, not string | undefinedAPI Response Validation
import { z } from 'zod';
const PaginatedResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
items: z.array(itemSchema),
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
});
const ArticleSchema = z.object({
id: z.string().uuid(),
title: z.string(),
publishedAt: z.string().datetime().nullable(),
tags: z.array(z.string()),
});
// Reusable: PaginatedResponse<Article>
const ArticleListSchema = PaginatedResponseSchema(ArticleSchema);
type ArticleList = z.infer<typeof ArticleListSchema>;
async function fetchArticles(page: number): Promise<ArticleList> {
const res = await fetch(`/api/articles?page=${page}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// Throws if the API returns unexpected shape
return ArticleListSchema.parse(await res.json());
}Form Validation with React Hook Form
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const SignUpSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'At least one uppercase letter')
.regex(/[0-9]/, 'At least one number'),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{ message: 'Passwords do not match', path: ['confirmPassword'] }
);
type SignUpForm = z.infer<typeof SignUpSchema>;
function SignUpForm() {
const { register, handleSubmit, formState: { errors } } = useForm<SignUpForm>({
resolver: zodResolver(SignUpSchema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
{/* ... */}
</form>
);
}Transformations and Preprocessing
// .transform() converts the parsed value to a different type
const DateStringSchema = z.string().datetime().transform((s) => new Date(s));
type ParsedDate = z.infer<typeof DateStringSchema>; // Date β not string!
// .preprocess() runs before validation β useful for coercion
const FlexibleNumberSchema = z.preprocess(
(val) => (typeof val === 'string' ? Number(val) : val),
z.number()
);
// .pipe() chains schemas
const TrimmedEmailSchema = z.string().transform((s) => s.trim()).pipe(z.string().email());
// Practical: parse a query param that might be a string number
const PageSchema = z.coerce.number().int().positive().default(1);
const page = PageSchema.parse(searchParams.get('page')); // "2" β 2Error Handling
import { z } from 'zod';
const result = UserSchema.safeParse(unknownData);
if (!result.success) {
// .flatten() β simple field β messages map
const flat = result.error.flatten();
// { fieldErrors: { name: ['Required'], email: ['Invalid email'] }, formErrors: [] }
// .format() β nested structure matching the schema shape
const formatted = result.error.format();
// Individual issues
result.error.issues.forEach((issue) => {
console.log(issue.path.join('.'), issue.message, issue.code);
});
}
// In a Next.js Server Action:
export async function createUser(formData: FormData) {
const result = UserSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// result.data is typed as User
await db.user.create({ data: result.data });
}Pitfalls
Using .parse() in request handlers
parse() throws a ZodError β which becomes a 500 in most frameworks. Use safeParse() in request handlers and return 400 on validation failure. Reserve parse() for startup-time validation (env vars) where throwing is the correct behaviour.
Inferring after transform
z.infer<typeof Schema> gives you the output type after transforms. If you need the input type (what goes into parse()), use z.input<typeof Schema>.
Schema reuse vs recreation
Define schemas as module-level constants, not inside components or route handlers. Recreating schemas on every render is wasteful and breaks referential equality for memoisation.

