Zod bridges TypeScript’s compile-time types and JavaScript’s runtime. TypeScript types disappear at runtime — API responses can contain anything, form inputs are always strings, environment variables are unknown. Zod defines schemas that validate data at runtime AND infer TypeScript types from the schema, so you write the shape once. Claude Code writes Zod schemas for API requests, forms, environment configs, and the error handling that surfaces validation failures clearly.
CLAUDE.md for Zod Projects
## Validation Stack
- Zod 3.x for all runtime validation
- z.infer<typeof schema> for TypeScript types — no separate type definitions
- API boundaries: validate all incoming requests at the edge (middleware or route handler)
- Environment: z.object().parse(process.env) at startup — fail fast on missing vars
- Forms: React Hook Form with zodResolver — schema drives both validation and types
- Error handling: ZodError.flatten() for user-facing messages; z.safeParse() at boundaries
Core Schema Patterns
// schemas/order.ts — define schema, infer type from it
import { z } from 'zod';
// Reusable primitives
const PositiveInt = z.number().int().positive();
const NonEmptyString = z.string().min(1).max(255).trim();
const EmailSchema = z.string().email().toLowerCase();
const UUIDSchema = z.string().uuid();
// Enums — single source of truth for valid values
const OrderStatusSchema = z.enum([
'pending', 'processing', 'shipped', 'delivered', 'cancelled'
]);
const OrderItemSchema = z.object({
productId: UUIDSchema,
quantity: PositiveInt.max(100),
unitPriceCents: PositiveInt,
productName: NonEmptyString,
});
export const CreateOrderSchema = z.object({
customerId: UUIDSchema,
items: z.array(OrderItemSchema).min(1, 'At least one item required').max(50),
shippingAddress: z.object({
street: NonEmptyString,
city: NonEmptyString,
country: z.string().length(2).toUpperCase(),
postalCode: z.string().min(3).max(10).regex(/^[A-Z0-9\s-]+$/i),
}),
currency: z.string().length(3).toUpperCase().default('USD'),
notes: z.string().max(500).optional(),
});
// Type inferred from schema — no duplication
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
// Response schema — validate what the API returns
export const OrderSchema = CreateOrderSchema.extend({
id: UUIDSchema,
status: OrderStatusSchema,
totalCents: PositiveInt,
createdAt: z.coerce.date(), // coerce: accepts ISO string or Date
updatedAt: z.coerce.date(),
});
export type Order = z.infer<typeof OrderSchema>;
API Request Validation (Express/Fastify)
// middleware/validate.ts — Express middleware for request validation
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
export function validateBody<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
// Flatten to field-level errors for API clients
const errors = result.error.flatten().fieldErrors;
return res.status(400).json({
error: 'Validation failed',
fields: errors,
});
}
// Replace body with parsed (transformed) value
req.body = result.data;
next();
};
}
// Usage in route:
// router.post('/orders', validateBody(CreateOrderSchema), createOrderHandler);
// For query params:
export function validateQuery<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({ error: 'Invalid query parameters' });
}
req.query = result.data as any;
next();
};
}
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: OrderStatusSchema.optional(),
});
Environment Variable Validation
// config/env.ts — fail fast on startup if env is misconfigured
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1000).max(65535).default(3000),
DATABASE_URL: z.string().url().startsWith('postgresql://'),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
REDIS_URL: z.string().url().startsWith('redis://').optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
// Parse at module load — throws immediately if invalid
export const env = EnvSchema.parse(process.env);
// TypeScript now knows env.PORT is a number, env.NODE_ENV is a union type, etc.
React Hook Form + Zod
// components/CreateOrderForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CreateOrderSchema, type CreateOrderInput } from '../schemas/order';
export function CreateOrderForm({ onSubmit }: { onSubmit: (data: CreateOrderInput) => void }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<CreateOrderInput>({
resolver: zodResolver(CreateOrderSchema),
defaultValues: {
currency: 'USD',
items: [{ productId: '', quantity: 1, unitPriceCents: 0, productName: '' }],
},
});
const submit = async (data: CreateOrderInput) => {
try {
await onSubmit(data);
} catch (err) {
// Map server validation errors back to form fields
if (err.response?.data?.fields) {
for (const [field, messages] of Object.entries(err.response.data.fields)) {
setError(field as any, { message: (messages as string[])[0] });
}
}
}
};
return (
<form onSubmit={handleSubmit(submit)}>
<div>
<label htmlFor="customerId">Customer ID</label>
<input id="customerId" {...register('customerId')} />
{errors.customerId && <span className="error">{errors.customerId.message}</span>}
</div>
<div>
<label htmlFor="street">Street</label>
<input id="street" {...register('shippingAddress.street')} />
{errors.shippingAddress?.street && (
<span className="error">{errors.shippingAddress.street.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Placing...' : 'Place Order'}
</button>
</form>
);
}
Custom Refinements and Transforms
// schemas/advanced.ts — complex validation with refinements
import { z } from 'zod';
// Refine: add cross-field validation
const DateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(
(data) => data.endDate > data.startDate,
{
message: 'End date must be after start date',
path: ['endDate'], // Error shows on this field
}
);
// Transform: shape data on parse
const MoneySchema = z.object({
amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
currency: z.string().length(3),
}).transform((val) => ({
...val,
amountCents: Math.round(parseFloat(val.amount) * 100),
currency: val.currency.toUpperCase(),
}));
// z.discriminatedUnion: efficient type narrowing
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('order.created'), orderId: z.string() }),
z.object({ type: z.literal('payment.completed'), chargeId: z.string(), amount: z.number() }),
z.object({ type: z.literal('order.cancelled'), orderId: z.string(), reason: z.string() }),
]);
type Event = z.infer<typeof EventSchema>;
// TypeScript narrows correctly after discriminating on type field
For the tRPC integration that uses Zod schemas for end-to-end type safety, the tRPC guide shows how Zod input schemas drive both router validation and the TypeScript client. For the React form patterns built on top of this validation layer, the React frontend guide covers controlled/uncontrolled components with validation UX. The Claude Skills 360 bundle includes Zod skill sets covering schema design, env validation, form integration, and API boundary patterns. Start with the free tier to try Zod schema generation.