Zod is a TypeScript-first schema validation library. It solves a common problem: TypeScript types only exist at compile time, but you need to validate data at runtime—API responses, form input, config files, and other external data. Zod validates runtime data and infers TypeScript types from the same schema, keeping them in sync.
Why Zod?
| Problem | Zod solution |
|---|---|
API data is any |
Parse with schema, get typed data |
| Form input untrusted | Validate before processing |
| Types and validation diverge | Single source of truth |
| Verbose validation code | Declarative schema definition |
// Without Zod: types and validation separate
interface User {
name: string;
email: string;
}
function validateUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'name' in data &&
typeof data.name === 'string' &&
'email' in data &&
typeof data.email === 'string'
);
}
// With Zod: single schema, types inferred
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
Installation
npm install zod
Zod has no dependencies and works in any JavaScript environment.
Basic schemas
Primitives
import { z } from 'zod';
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const bigintSchema = z.bigint();
const undefinedSchema = z.undefined();
const nullSchema = z.null();
Objects
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user', 'guest']),
createdAt: z.date(),
});
type User = z.infer<typeof UserSchema>;
Arrays
const StringArraySchema = z.array(z.string());
const UserArraySchema = z.array(UserSchema);
// With constraints
const TagsSchema = z.array(z.string()).min(1).max(10);
Unions and enums
// Union
const ResultSchema = z.union([
z.object({ status: z.literal('success'), data: z.string() }),
z.object({ status: z.literal('error'), message: z.string() }),
]);
// Discriminated union (more efficient)
const ResultSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: z.string() }),
z.object({ status: z.literal('error'), message: z.string() }),
]);
// Enum
const StatusSchema = z.enum(['pending', 'active', 'completed']);
type Status = z.infer<typeof StatusSchema>; // 'pending' | 'active' | 'completed'
Parsing data
parse vs safeParse
// parse: throws on invalid data
try {
const user = UserSchema.parse(unknownData);
// user is typed as User
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues);
}
}
// safeParse: returns result object
const result = UserSchema.safeParse(unknownData);
if (result.success) {
const user = result.data; // User
} else {
const errors = result.error.issues;
}
Use safeParse for form validation where you want to show errors. Use parse when invalid data is unexpected.
Error formatting
const result = UserSchema.safeParse(formData);
if (!result.success) {
// Flat errors by path
const fieldErrors = result.error.flatten().fieldErrors;
// { name: ['Required'], email: ['Invalid email'] }
// Formatted errors
const formatted = result.error.format();
// { name: { _errors: ['Required'] }, email: { _errors: ['Invalid email'] } }
}
String validations
z.string().min(1, 'Required')
z.string().max(100, 'Too long')
z.string().length(5)
z.string().email('Invalid email')
z.string().url('Invalid URL')
z.string().uuid()
z.string().regex(/^[a-z]+$/, 'Lowercase only')
z.string().startsWith('https://')
z.string().endsWith('.com')
z.string().trim() // Transform: trims whitespace
z.string().toLowerCase() // Transform: lowercases
Number validations
z.number().int()
z.number().positive()
z.number().negative()
z.number().nonnegative()
z.number().min(0)
z.number().max(100)
z.number().multipleOf(5)
z.number().finite()
z.number().safe() // JavaScript safe integer
Optional and nullable
// Optional: can be undefined
z.string().optional() // string | undefined
// Nullable: can be null
z.string().nullable() // string | null
// Both
z.string().nullish() // string | null | undefined
// With default
z.string().default('anonymous')
z.number().default(() => Date.now())
Transformations
// Coerce strings to numbers
const NumberFromString = z.coerce.number();
NumberFromString.parse('42'); // 42
// Custom transform
const TrimmedString = z.string().transform((s) => s.trim());
// Transform with refinement
const PositiveString = z
.string()
.transform((s) => parseInt(s, 10))
.refine((n) => n > 0, 'Must be positive');
Custom validation
// Simple refinement
const PasswordSchema = z
.string()
.min(8)
.refine(
(password) => /[A-Z]/.test(password),
'Must contain uppercase letter'
)
.refine(
(password) => /[0-9]/.test(password),
'Must contain number'
);
// Superrefine for complex validation
const FormSchema = z
.object({
password: z.string(),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Passwords must match',
path: ['confirmPassword'],
});
}
});
Composition
Extending objects
const BaseUserSchema = z.object({
id: z.string(),
email: z.string().email(),
});
const AdminSchema = BaseUserSchema.extend({
role: z.literal('admin'),
permissions: z.array(z.string()),
});
Pick and omit
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UserEmailSchema = UserSchema.pick({ email: true });
Partial and required
// All fields optional
const PartialUserSchema = UserSchema.partial();
// All fields required
const RequiredUserSchema = PartialUserSchema.required();
// Specific fields optional
const UpdateUserSchema = UserSchema.partial({ name: true, age: true });
With React Hook Form
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const FormSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Too short'),
});
type FormData = z.infer<typeof FormSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(FormSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
);
}
With Server Actions
'use server';
import { z } from 'zod';
const ContactSchema = z.object({
name: z.string().min(1, 'Required'),
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Too short'),
});
export async function submitContact(prevState: any, formData: FormData) {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
const result = ContactSchema.safeParse(raw);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
success: false,
};
}
await saveContact(result.data);
return { errors: null, success: true };
}
API validation
// app/api/users/route.ts
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export async function POST(request: Request) {
const body = await request.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: result.error.flatten() },
{ status: 400 }
);
}
const user = await createUser(result.data);
return Response.json(user, { status: 201 });
}
Summary
Zod validates data at runtime and infers TypeScript types from the same schema. Define schemas with primitives, objects, arrays, and unions. Use safeParse for form validation, parse for unexpected failures. Add refinements for custom validation. Compose schemas with extend, pick, omit, and partial. Integrate with React Hook Form using zodResolver and with Server Actions by parsing FormData.
