Overview
Validate at every trust boundary — API inputs, environment variables, external API responses, form data. Zod provides runtime validation with TypeScript type inference. Never trust data from outside your process boundary.
Core Patterns
Basic Schema and Inference
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(["admin", "user", "viewer"]),
createdAt: z.string().datetime(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;
// Parse (throws on failure)
const user = UserSchema.parse(data);
// Safe parse (returns result object)
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error(result.error.flatten());
} else {
const user = result.data;
}
Schema Composition
// Base schemas
const IdSchema = z.string().uuid();
const TimestampSchema = z.string().datetime();
const BaseEntitySchema = z.object({
id: IdSchema,
createdAt: TimestampSchema,
updatedAt: TimestampSchema,
});
// Extend base
const ProjectSchema = BaseEntitySchema.extend({
name: z.string().min(1).max(100),
status: z.enum(["active", "archived", "deleted"]),
userId: IdSchema,
});
// Create insert schema (omit server-generated fields)
const CreateProjectSchema = ProjectSchema.omit({ id: true, createdAt: true, updatedAt: true });
const UpdateProjectSchema = ProjectSchema.partial().required({ id: true });
type Project = z.infer<typeof ProjectSchema>;
type CreateProject = z.infer<typeof CreateProjectSchema>;
type UpdateProject = z.infer<typeof UpdateProjectSchema>;
API Route Validation Pattern
// Always validate at the API boundary
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null);
if (!body) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const result = CreateProjectSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
error: "Validation failed",
details: result.error.flatten().fieldErrors,
},
{ status: 422 }
);
}
const project = await createProject(result.data);
return NextResponse.json({ project }, { status: 201 });
}
Environment Variable Validation
// src/lib/env.ts — run at startup, fails fast on missing vars
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
});
export const env = EnvSchema.parse(process.env);
Advanced Patterns
Discriminated Unions
const ApiResponseSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("success"),
data: z.unknown(),
}),
z.object({
status: z.literal("error"),
code: z.number(),
message: z.string(),
}),
]);
type ApiResponse = z.infer<typeof ApiResponseSchema>;
function handleResponse(raw: unknown): void {
const response = ApiResponseSchema.parse(raw);
if (response.status === "success") {
// TypeScript knows data is available here
processData(response.data);
} else {
// TypeScript knows code and message are available here
logError(response.code, response.message);
}
}
Transform Pipeline
const StripeAmountSchema = z
.number()
.int()
.positive()
.transform((cents) => ({
cents,
dollars: cents / 100,
formatted: new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100),
}));
// StripeAmountSchema.parse(2900)
// → { cents: 2900, dollars: 29, formatted: "$29.00" }
Branded Types
// Prevent confusion between IDs of different entity types
const UserIdSchema = z.string().uuid().brand("UserId");
const ProjectIdSchema = z.string().uuid().brand("ProjectId");
type UserId = z.infer<typeof UserIdSchema>;
type ProjectId = z.infer<typeof ProjectIdSchema>;
// TypeScript error: can't pass ProjectId where UserId is expected
function getUser(id: UserId): Promise<User> { /* ... */ }
const projectId = ProjectIdSchema.parse("123e4567-...");
getUser(projectId); // TS Error: Type 'ProjectId' is not assignable to 'UserId'
Coercion for Query Params
// URL params are always strings — coerce to the right type
const SearchParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(["active", "archived"]).optional(),
search: z.string().optional(),
});
export async function GET(request: NextRequest) {
const params = Object.fromEntries(new URL(request.url).searchParams);
const { page, limit, status, search } = SearchParamsSchema.parse(params);
// page and limit are numbers, not strings
}
External API Response Validation
// Always validate responses from external APIs
const GHLContactSchema = z.object({
id: z.string(),
email: z.string().email().optional(),
phone: z.string().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
});
const GHLContactListSchema = z.object({
contacts: z.array(GHLContactSchema),
meta: z.object({
total: z.number(),
count: z.number(),
currentPage: z.number(),
}),
});
async function fetchContacts() {
const raw = await ghlClient.get("/contacts");
const validated = GHLContactListSchema.parse(raw);
return validated;
}
Error Formatting
const result = UserSchema.safeParse(data);
if (!result.success) {
// Field-level errors
const fieldErrors = result.error.flatten().fieldErrors;
// { email: ["Invalid email"], name: ["Too short"] }
// Flat list of errors
const issues = result.error.issues.map(issue => ({
path: issue.path.join("."),
message: issue.message,
}));
// First error only
const firstError = result.error.issues[0]?.message;
}
TypeScript Strict Mode
Always enable in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true
}
}
strict: true enables: strictNullChecks, strictFunctionTypes, strictPropertyInitialization, noImplicitAny.