Zod Schema Validation: The Complete Guide for TypeScript Developers
Master Zod schema validation in TypeScript. Learn how to define schemas, validate API responses, parse form data, infer types, compose schemas, and handle errors — with 10+ real-world code examples.
Zod is a TypeScript-first schema declaration and validation library. The pitch is simple: define a schema once, get both runtime validation and compile-time type inference from the same source. No more writing interface UserInput and then separately writing validation logic that may drift out of sync.
This guide covers Zod from first principles to production patterns. You’ll learn how to define schemas, compose them, validate real-world data shapes, handle errors gracefully, and integrate Zod into the most common use cases: API response parsing, form validation, and environment variable configuration.
TL;DR
- Install with
npm install zod— no extra setup needed z.object(),z.string(),z.number(),z.array()are your core building blocks.parse()throws on invalid data;.safeParse()returns{ success, data, error }— prefersafeParsein productionz.infer<typeof MySchema>generates the TypeScript type — no duplicate type definitions- Compose schemas with
.merge(),.extend(),.partial(),.pick(),.omit() z.discriminatedUnion()is the right way to model union types with a tag field
Why Zod Instead of Manual Validation
TypeScript types vanish at runtime. This is a fundamental constraint: when your Node.js server receives a JSON body, or your frontend receives an API response, TypeScript has no way to verify the shape of that data. You must validate it manually — or use a library.
Before Zod, the common approach was to write manual checks:
// The old way — error-prone and doesn't scale
function validateUser(data: unknown) {
if (typeof data !== "object" || data === null) {
throw new Error("Expected object");
}
const d = data as Record<string, unknown>;
if (typeof d.name !== "string") throw new Error("name must be string");
if (typeof d.email !== "string") throw new Error("email must be string");
if (typeof d.age !== "number") throw new Error("age must be number");
return d as { name: string; email: string; age: number };
}
This is verbose, hard to maintain, and the returned type relies on a type assertion — not actual verification. Zod solves all three problems.
Installation and Basic Setup
npm install zod
# or
pnpm add zod
# or
yarn add zod
Zod requires TypeScript 4.5+ and strict: true in your tsconfig.json. Without strict mode, type inference degrades significantly.
// tsconfig.json — minimum recommended config
{
"compilerOptions": {
"strict": true,
"target": "ES2020"
}
}
Core Primitives
Every Zod schema starts with a primitive:
import { z } from "zod";
// Primitive types
const nameSchema = z.string();
const ageSchema = z.number();
const isActiveSchema = z.boolean();
const tagsSchema = z.array(z.string());
const dataSchema = z.unknown(); // accepts anything
// Literal types
const roleSchema = z.literal("admin");
const statusSchema = z.union([
z.literal("active"),
z.literal("inactive"),
z.literal("pending"),
]);
// Enums (prefer z.enum for string unions)
const DirectionSchema = z.enum(["north", "south", "east", "west"]);
type Direction = z.infer<typeof DirectionSchema>; // "north" | "south" | "east" | "west"
String Validations
Zod’s string type has a rich set of built-in validators:
const emailSchema = z.string().email("Invalid email format");
const urlSchema = z.string().url("Must be a valid URL");
const uuidSchema = z.string().uuid();
const slugSchema = z.string().regex(/^[a-z0-9-]+$/, "Must be a URL slug");
const passwordSchema = z
.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password too long")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[0-9]/, "Must contain a number");
Number Validations
const ageSchema = z.number().int("Must be an integer").min(0).max(150);
const priceSchema = z.number().positive("Price must be positive").finite();
const ratingSchema = z.number().min(1).max(5).multipleOf(0.5);
Object Schemas — The Core Pattern
The z.object() schema is what you’ll use most. It validates the shape of an object and infers the TypeScript type:
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, "Name is required"),
email: z.string().email(),
age: z.number().int().min(0).optional(),
role: z.enum(["admin", "editor", "viewer"]),
createdAt: z.string().datetime(),
});
// Infer the TypeScript type — no duplicate definition needed
type User = z.infer<typeof UserSchema>;
// Parse (throws on error)
const user = UserSchema.parse({
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Alice",
email: "[email protected]",
role: "admin",
createdAt: "2026-03-27T10:00:00Z",
});
// user is typed as User
safeParse — For Production Use
.parse() throws a ZodError on invalid input. In production code, prefer .safeParse() which returns a discriminated union:
const result = UserSchema.safeParse(untrustedInput);
if (!result.success) {
// result.error is a ZodError with full path information
console.error(result.error.format());
// → { name: { _errors: ["Name is required"] }, email: { _errors: ["Invalid email"] } }
return { error: "Invalid input" };
}
// result.data is typed as User
const user = result.data;
Composing Schemas
Zod schemas are composable. This is where the library really shines — you can build complex shapes from simple pieces without repetition.
.extend() — Add Fields
const BaseUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const AdminUserSchema = BaseUserSchema.extend({
role: z.literal("admin"),
permissions: z.array(z.string()),
});
type AdminUser = z.infer<typeof AdminUserSchema>;
// { id: string; email: string; role: "admin"; permissions: string[] }
.partial() and .required() — Optional Fields
const UpdateUserSchema = UserSchema.partial(); // all fields optional
const StrictUserSchema = UserSchema.required(); // all fields required (including optional ones)
// Selective partial — only make specific fields optional
const PatchUserSchema = UserSchema.partial({ name: true, age: true });
.pick() and .omit() — Subset Schemas
// Only include specific fields
const PublicUserSchema = UserSchema.pick({ id: true, name: true, role: true });
// Exclude sensitive fields
const SafeUserSchema = UserSchema.omit({ password: true, internalId: true });
.merge() — Combine Two Object Schemas
const TimestampsSchema = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const PersistedUserSchema = UserSchema.merge(TimestampsSchema);
Nested Objects and Arrays
Real-world data is nested. Zod handles arbitrary nesting cleanly:
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2, "Use 2-letter country code"),
zip: z.string().optional(),
});
const OrderSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})
).min(1, "Order must have at least one item"),
shippingAddress: AddressSchema,
status: z.enum(["pending", "processing", "shipped", "delivered", "cancelled"]),
total: z.number().positive(),
});
type Order = z.infer<typeof OrderSchema>;
Discriminated Unions — Modeling Tagged Types
When you have a union where one field acts as a discriminant (like a type or kind field), use z.discriminatedUnion() for better performance and error messages:
const EventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("user.created"),
userId: z.string(),
email: z.string().email(),
}),
z.object({
type: z.literal("user.deleted"),
userId: z.string(),
deletedAt: z.string().datetime(),
}),
z.object({
type: z.literal("order.placed"),
orderId: z.string(),
total: z.number(),
}),
]);
type Event = z.infer<typeof EventSchema>;
function handleEvent(event: Event) {
switch (event.type) {
case "user.created":
// TypeScript knows event.email exists here
sendWelcomeEmail(event.email);
break;
case "order.placed":
// TypeScript knows event.orderId and event.total exist here
processOrder(event.orderId, event.total);
break;
}
}
Real-World Use Cases
Use Case 1: Validating API Responses
When consuming external APIs, you can’t trust the response shape. Parse it with Zod to get both safety and typed data:
const GitHubUserSchema = z.object({
login: z.string(),
id: z.number(),
avatar_url: z.string().url(),
name: z.string().nullable(),
public_repos: z.number().int().min(0),
followers: z.number().int().min(0),
});
type GitHubUser = z.infer<typeof GitHubUserSchema>;
async function fetchGitHubUser(username: string): Promise<GitHubUser> {
const response = await fetch(`https://api.github.com/users/${username}`);
const data = await response.json();
const result = GitHubUserSchema.safeParse(data);
if (!result.success) {
throw new Error(`Unexpected GitHub API response: ${result.error.message}`);
}
return result.data;
}
Use Case 2: Environment Variable Validation
Catch misconfigured environments at startup rather than at runtime:
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
PORT: z.string().transform(Number).pipe(z.number().int().min(1).max(65535)),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
REDIS_URL: z.string().url().optional(),
});
// Validate at app startup — fail fast
const env = EnvSchema.parse(process.env);
// env.PORT is typed as number (after .transform(Number))
export { env };
Use Case 3: Form Validation with React Hook Form
Zod integrates directly with React Hook Form via @hookform/resolvers:
npm install @hookform/resolvers
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const SignupSchema = z
.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type SignupForm = z.infer<typeof SignupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupForm>({
resolver: zodResolver(SignupSchema),
});
const onSubmit = (data: SignupForm) => {
// data is fully typed and validated
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
Advanced Patterns
Transformations — Parse into a Different Shape
.transform() lets you parse raw data into a different type:
const DateStringSchema = z.string().transform((str) => new Date(str));
// z.infer gives `Date`, input is `string`
const TrimmedStringSchema = z.string().trim().toLowerCase();
const CoercedNumberSchema = z.coerce.number(); // z.coerce handles "42" → 42 automatically
Refinements — Custom Validation Logic
.refine() adds custom validation that can’t be expressed with built-in validators:
const DateRangeSchema = z
.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
})
.refine((data) => new Date(data.endDate) > new Date(data.startDate), {
message: "End date must be after start date",
path: ["endDate"],
});
// .superRefine for multiple errors
const PasswordSchema = z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({ code: z.ZodIssueCode.too_small, minimum: 8, type: "string", inclusive: true, message: "Too short" });
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Needs uppercase" });
}
});
Lazy Schemas — Recursive Types
For recursive data structures like trees or nested comments:
interface Comment {
id: string;
text: string;
replies: Comment[];
}
const CommentSchema: z.ZodType<Comment> = z.lazy(() =>
z.object({
id: z.string(),
text: z.string(),
replies: z.array(CommentSchema),
})
);
Error Handling in Depth
ZodError contains a structured list of issues with path information:
const result = UserSchema.safeParse({
name: "",
email: "not-an-email",
age: -5,
});
if (!result.success) {
// Flat list of errors
console.log(result.error.issues);
// [
// { path: ["name"], message: "Name is required", code: "too_small" },
// { path: ["email"], message: "Invalid email", code: "invalid_string" },
// { path: ["age"], message: "Number must be greater than or equal to 0", code: "too_small" }
// ]
// Formatted nested object (good for forms)
console.log(result.error.format());
// { name: { _errors: ["Name is required"] }, email: { _errors: ["Invalid email"] } }
// Flat map keyed by path
const fieldErrors = result.error.flatten().fieldErrors;
// { name: ["Name is required"], email: ["Invalid email"], age: ["..."] }
}
Zod vs Manual Validation: The Performance Question
Zod adds overhead compared to hand-written validation — benchmarks show roughly 3–10x slower parsing for simple objects. For most applications, this is irrelevant: the bottleneck is your database query or network call, not schema parsing.
If you’re validating millions of records per second in a hot path, consider Valibot (modular, smaller bundle) or hand-written validation for those specific paths. For everything else, the developer experience and type safety Zod provides is worth the tradeoff.
Key Takeaways
- Use
z.infer<typeof Schema>instead of writing duplicate TypeScript types - Prefer
.safeParse()over.parse()in production — it doesn’t throw - Compose schemas with
.extend(),.partial(),.pick(),.omit()instead of duplicating definitions - Use
z.discriminatedUnion()for tagged union types - Validate environment variables at startup with
EnvSchema.parse(process.env)— fail fast .transform()lets you convert data shapes during parsing (strings to Dates, strings to numbers).refine()handles cross-field validation that built-in validators can’t express
50 CLI Commands That Save Hours Every Week
The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.
- Git mastery (10 cmds)
- File & search wizardry
- Docker & DevOps tricks
- Process & network tools
- Productivity boosters
- macOS & Linux install guide
No spam. 1 email/week max. Unsubscribe anytime.
Found this guide useful? Check out our free developer tools.
Affiliate disclosure: Some links below are affiliate links — we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.