Zod from Beginner to Expert: A Learning Path
Runtime data validation is one of the most common challenges in modern TypeScript applications. Whether you are parsing API responses, reading environment variables, or validating form inputs, you need a way to ensure the data matches the shape your code expects. Zod is a TypeScript-first schema declaration and validation library that solves this problem elegantly, providing both runtime validation and static type inference from a single source of truth.
What Is Zod and Why It Matters
Zod is a schema validation library built specifically for TypeScript. Unlike JSON Schema or Joi, Zod is designed from the ground up to integrate with TypeScript's type system. You define a schema once, and Zod automatically infers the corresponding TypeScript type, eliminating the duplication that occurs when you maintain separate types and validators.
The Problem Zod Solves
TypeScript types only exist at compile time. Once your code runs, all type information is erased. This means that any data coming from external sources — HTTP responses, user input, configuration files, databases — cannot be trusted to match your declared types. Without runtime validation, you are essentially hoping the data is correct, which leads to cryptic errors deep in your application logic.
Consider a typical scenario: you fetch user data from an API and assign it a TypeScript interface. If the API returns unexpected data, TypeScript will not protect you at runtime. Zod bridges this gap by validating data when it enters your system, failing fast with clear error messages before the data reaches your business logic.
Key Benefits
- Single source of truth: Define a schema once and derive both the validator and the TypeScript type.
- Tree-shakeable: Zod is designed to work well with modern bundlers, keeping your bundle size small.
- Composable: Schemas can be combined, extended, and transformed, making complex validation logic manageable.
- Excellent error messages: Built-in error reporting is detailed and customizable.
- Zero dependencies: Zod has no runtime dependencies, making it lightweight and secure.
Getting Started: Installation and First Schema
To begin using Zod, install it as a dependency in your TypeScript project. Ensure you have TypeScript configured with strict mode enabled for the best experience.
npm install zod
Here is a simple example demonstrating the core workflow: define a schema, validate data, and infer the type.
import { z } from "zod";
// Define a schema
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
isActive: z.boolean().default(true),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// Validate data
const result = UserSchema.safeParse({
id: 1,
name: "Alice",
email: "alice@example.com",
});
if (result.success) {
// result.data is fully typed as User
console.log(result.data.name); // "Alice"
} else {
console.error(result.error.issues);
}
Notice how z.infer extracts the TypeScript type from the schema. This means you never have to manually write an interface that mirrors your schema — Zod handles it automatically, and the two can never drift apart.
Core Primitives and Types
Zod provides schema constructors for all JavaScript primitives and many common data structures. Understanding these building blocks is essential before moving to more advanced patterns.
Primitive Schemas
const primitives = z.object({
string: z.string(),
number: z.number(),
bigint: z.bigint(),
boolean: z.boolean(),
date: z.date(),
symbol: z.symbol(),
undefined: z.undefined(),
null: z.null(),
void: z.void(),
any: z.any(),
unknown: z.unknown(),
never: z.never(),
});
String Validation
String schemas support a rich set of validation methods that cover most common use cases.
const password = z.string()
.min(8, "Password must be at least 8 characters")
.max(100, "Password is too long")
.regex(/[A-Z]/, "Must contain at least one uppercase letter")
.regex(/[0-9]/, "Must contain at least one number");
const email = z.string().email("Invalid email address");
const url = z.string().url("Invalid URL");
const uuid = z.string().uuid("Invalid UUID");
const isoDate = z.string().datetime();
Number Validation
const age = z.number()
.int("Age must be an integer")
.min(0, "Age cannot be negative")
.max(150, "Age seems unrealistic");
const price = z.number()
.positive("Price must be positive")
.multipleOf(0.01, "Price can have at most 2 decimal places");
Working with Objects and Arrays
Objects and arrays are the most common structures you will validate. Zod provides flexible options for handling optional fields, default values, and strictness.
Object Schemas
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
price: z.number().positive(),
description: z.string().optional(),
tags: z.array(z.string()).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
});
type Product = z.infer<typeof ProductSchema>;
// By default, Zod strips unknown keys
const input = {
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Widget",
price: 9.99,
extraField: "this will be stripped",
};
const product = ProductSchema.parse(input);
// product.extraField does not exist on the type
Strict and Passthrough Modes
// strict: throws an error on unknown keys
const StrictSchema = z.object({
name: z.string(),
}).strict();
// passthrough: keeps unknown keys in the output
const PassthroughSchema = z.object({
name: z.string(),
}).passthrough();
// catchall: define a schema for all unknown keys
const CatchallSchema = z.object({
name: z.string(),
}).catchall(z.number());
Arrays
const StringArray = z.array(z.string())
.min(1, "At least one item required")
.max(10, "Too many items");
const NonEmptyStringArray = z.array(z.string()).nonempty();
// Tuple with fixed length and types
const Tuple = z.tuple([
z.string(), // first element
z.number(), // second element
z.boolean(), // third element
]);
Unions, Enums, and Literals
Real-world data often involves choices between multiple shapes. Zod provides several constructs for representing these.
Literal Types
const Role = z.literal("admin");
type RoleType = z.infer<typeof Role>; // "admin"
const Status = z.union([
z.literal("pending"),
z.literal("active"),
z.literal("inactive"),
]);
Enums
For string unions, Zod enums are cleaner than unions of literals.
const UserRole = z.enum(["admin", "editor", "viewer"]);
type UserRole = z.infer<typeof UserRole>; // "admin" | "editor" | "viewer"
// Access values at runtime
console.log(UserRole.enum.admin); // "admin"
console.log(UserRole.options); // ["admin", "editor", "viewer"]
Discriminated Unions
Discriminated unions are one of Zod's most powerful features. They allow you to model data that can take different shapes based on a common discriminator field, and Zod will validate efficiently by checking only the relevant schema.
const Shape = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("circle"),
radius: z.number().positive(),
}),
z.object({
kind: z.literal("square"),
sideLength: z.number().positive(),
}),
z.object({
kind: z.literal("rectangle"),
width: z.number().positive(),
height: z.number().positive(),
}),
]);
type Shape = z.infer<typeof Shape>;
const circle = Shape.parse({ kind: "circle", radius: 5 });
const square = Shape.parse({ kind: "square", sideLength: 10 });
Regular Unions
When you cannot use a discriminator, regular unions work but are less efficient because Zod tries each option in order.
const StringOrNumber = z.union([z.string(), z.number()]);
// Shorthand syntax
const StringOrNumberShort = z.string().or(z.number());
Optional, Nullable, and Default Values
Understanding how Zod handles absence and nullability is crucial. These modifiers compose with any schema.
// optional: allows undefined
const optionalName = z.string().optional();
// nullable: allows null
const nullableName = z.string().nullable();
// both: allows undefined or null
const optionalNullable = z.string().optional().nullable();
// nullish: shorthand for optional().nullable()
const nullishName = z.string().nullish();
// default: provides a value when input is undefined
const withDefault = z.string().default("anonymous");
// Chaining order matters
const example = z.string().default("hello").optional();
// undefined -> undefined (default not applied because optional comes after)
Transformations and Refinements
Validation is often just the first step. You may need to transform data into a different shape or apply custom validation logic that built-in methods cannot express.
Transforms
// Transform a string to a trimmed, lowercase version
const trimmedLower = z.string().transform((val) => val.trim().toLowerCase());
// Transform a string to a number
const stringToNumber = z.string().transform((val) => parseFloat(val));
// Transform with a new type
const schema = z.string().transform((val) => val.length);
type Result = z.infer<typeof schema>; // number
Preprocess
Use z.preprocess to modify input before validation occurs. This is useful for normalizing data from forms or APIs.
const NumericString = z.preprocess(
(val) => (typeof val === "string" ? val.trim() : val),
z.string().regex(/^\d+$/).transform(Number)
);
const result = NumericString.parse(" 42 ");
console.log(result); // 42 (number)
Refinements
Refinements let you add custom validation logic. They are ideal for cross-field validation or business rules.
const PasswordSchema = z.string().refine(
(val) => val.length >= 8,
"Password must be at least 8 characters"
);
// Refine with context for cross-field validation
const FormSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
// Custom async validation (e.g., checking database)
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailInDatabase(email);
return !exists;
},
"Email already registered"
);
Super Refine
For multiple custom validations on a single schema, superRefine allows you to add multiple issues in one pass.
const PasswordSchema = z.string().superRefine((val, ctx) => {
if (val.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must be at least 8 characters",
fatal: true,
});
return;
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must contain an uppercase letter",
});
}
if (!/[0-9]/.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must contain a number",
});
}
});
Error Handling and Customization
Zod provides two methods for parsing data: parse throws on failure, while safeParse returns a result object. For most applications, safeParse is preferable because it gives you programmatic control over error handling.
const schema = z.object({
name: z.string().min(2),
age: z.number().int().positive(),
});
// Using safeParse
const result = schema.safeParse({ name: "A", age: -5 });
if (!result.success) {
for (const issue of result.error.issues) {
console.log(`${issue.path.join(".")}: ${issue.message}`);
// Output:
// name: String must contain at least 2 character(s)
// age: Number must be greater than 0
}
}
// Using parse with try/catch
try {
schema.parse({ name: "A", age: -5 });
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.issues);
}
}
Custom Error Maps
You can customize error messages globally or per-schema using error maps.
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
return { message: `Expected ${issue.expected}, received ${issue.received}` };
}
if (issue.code === z.ZodIssueCode.too_small) {
return { message: `Value is too small. Minimum is ${issue.minimum}` };
}
return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap);
Formatting Errors
const result = schema.safeParse(invalidData);
if (!result.success) {
// Flat array of issues
console.log(result.error.issues);
// Nested format matching your schema shape
console.log(result.error.format());
// Flattened format for form errors
console.log(result.error.flatten());
// {
// formErrors: [],
// fieldErrors: { name: ["String must contain at least 2 character(s)"] }
// }
}
Advanced Patterns
Recursive Schemas
Some data structures are recursive, such as trees or nested comments. Zod supports these using lazy evaluation.
interface Category {
name: string;
subcategories: Category[];
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
);
const data = {
name: "Electronics",
subcategories: [
{ name: "Phones", subcategories: [] },
{ name: "Laptops", subcategories: [
{ name: "Gaming", subcategories: [] }
] },
],
};
const parsed = CategorySchema.parse(data);
Intersection Types
const HasName = z.object({ name: z.string() });
const HasAge = z.object({ age: z.number() });
const Person = z.intersection(HasName, HasAge);
// Equivalent to: z.object({ name: z.string(), age: z.number() })
// For objects, merging is often cleaner
const PersonMerged = HasName.merge(HasAge).extend({
email: z.string().email(),
});
Partial, Pick, and Omit
Zod provides utility methods similar to TypeScript's utility types, allowing you to derive new schemas from existing ones.
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
});
// Make all fields optional
const UserUpdateSchema = UserSchema.partial();
// Equivalent to making every field optional
// Pick specific fields
const UserSummarySchema = UserSchema.pick({ name: true, email: true });
// Omit specific fields
const CreateUserSchema = UserSchema.omit({ id: true });
// Make specific fields optional
const PartialUpdateSchema = UserSchema.partial({ name: true, email: true });
// Extend with new fields
const AdminSchema = UserSchema.extend({
permissions: z.array(z.string()),
});
Record Schemas
// String-keyed record with string values
const StringRecord = z.record(z.string());
// String-keyed record with specific value type
const NumberRecord = z.record(z.string(), z.number());
// Useful for dictionaries and maps
const UserPermissions = z.record(
z.string(), // resource name
z.array(z.enum(["read", "write", "delete"])) // allowed actions
);
Real-World Use Cases
Validating API Responses
const ApiResponseSchema = z.object({
status: z.literal("success"),
data: z.object({
users: z.array(z.object({
id: z.string(),
name: z.string(),
email: z.string().email().optional(),
})),
pagination: z.object({
page: z.number(),
totalPages: z.number(),
hasNext: z.boolean(),
}),
}),
});
async function fetchUsers(page: number) {
const response = await fetch(`/api/users?page=${page}`);
const json = await response.json();
const result = ApiResponseSchema.safeParse(json);
if (!result.success) {
console.error("API response validation failed:", result.error);
throw new Error("Invalid API response");
}
return result.data.data;
}
Environment Variable Validation
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.string().transform(Number).default("3000"),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment variables:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Form Validation
const RegistrationFormSchema = z.object({
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be at most 20 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores"),
email: z.string().email("Please enter a valid email"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[a-z]/, "Must contain a lowercase letter")
.regex(/[0-9]/, "Must contain a number"),
confirmPassword: z.string(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms" }),
}),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
function validateForm(formData: FormData) {
const data = Object.fromEntries(formData);
const result = RegistrationFormSchema.safeParse(data);
if (result.success) {
return { success: true, data: result.data };
}
const errors = result.error.flatten().fieldErrors;
return { success: false, errors };
}
Integration with React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const FormSchema = z.object({
title: z.string().min(1, "Title is required"),
content: z.string().min(10, "Content must be at least 10 characters"),
});
type FormData = z.infer<typeof FormSchema>;
function PostForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(FormSchema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("title")} />
{errors.title && <p>{errors.title.message}</p>}
<textarea {...register("content")} />
{errors.content && <p>{errors.content.message}</p>}
<button type="submit">Submit</button>
</form>
);
}
Best Practices
Define Schemas at the Module Level
Avoid creating schemas inside functions or components. Schema construction has a small but nonzero cost, and recreating schemas on every render or function call is wasteful. Define them at the top level of your module and export both the schema and the inferred type.
// schemas/user.ts
import { z } from "zod";
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
});
export type User = z.infer<typeof UserSchema>;
Use safeParse Over parse
While parse is convenient for quick scripts, safeParse gives you better control in production code. It avoids throwing exceptions for expected validation failures, which should be treated as normal control flow rather than exceptional errors.
Compose Small Schemas
Break complex schemas into smaller, reusable pieces. This improves readability, testability, and maintainability.
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string().length(2),
zipCode: z.string().regex(/^\d{5}$/),
});
const PersonSchema = z.object({
name: z.string(),
address: AddressSchema,
});
const OrderSchema = z.object({
id: z.string().uuid(),
customer: PersonSchema,
shippingAddress: AddressSchema,
billingAddress: AddressSchema.optional(),
});
Be Explicit About Strictness
Decide intentionally whether unknown keys should be stripped, rejected, or passed through. The default stripping behavior is usually correct for API responses, but strict mode can catch bugs early during development.
Use Discriminated Unions for Variants
When data can take multiple shapes, prefer discriminated unions over regular unions. They are more efficient at runtime and produce better error messages because Zod knows exactly which schema to validate against based on the discriminator field.
Validate at the Boundary
Validate data once at the boundary of your system — when it enters from an API, a form, a file, or any external source. Once validated, you can trust the data throughout your application without re-validating it at every layer.
Leverage Type Inference
Never manually write TypeScript interfaces that duplicate your Zod schemas. Always use z.infer to derive types. This guarantees your types and validators stay in sync.
Handle Errors Gracefully
Transform Zod errors into user-friendly messages at the presentation layer. The raw error issues are useful for developers but may be confusing for end users. Map field errors to form inputs and provide actionable guidance.
Performance Considerations
Zod is generally fast enough for most applications, but there are a few things to keep in mind for performance-sensitive scenarios. Regular unions try each option sequentially, which can be slow with many options — use discriminated unions when possible. Schema construction is the most expensive operation, so define schemas once at module level. For extremely hot paths, consider caching parsed results or using Zod's z.coerce methods for simple type coercions instead of full transforms.
// Coercion is faster than transform for simple cases
const CoercedNumber = z.coerce.number();
const result = CoercedNumber.parse("42"); // 42 (number)
// Available coercions
const coerced = z.object({
str: z.coerce.string(),
num: z.coerce.number(),
bool: z.coerce.boolean(),
date: z.coerce.date(),
});
Conclusion
Zod has become the de facto standard for runtime validation in the TypeScript ecosystem, and for good reason. By unifying schema definition and type inference, it eliminates an entire class of bugs caused by type-validator drift while providing a rich, composable API for expressing even the most complex validation rules. Starting from simple primitives and building up to discriminated unions, transforms, and refinements, you now have the tools to validate data at every boundary of your application. The key to mastering Zod is to think in schemas: define your data shapes declaratively, let Zod handle both validation and type generation, and validate early at system boundaries so the rest of your code can operate with confidence. As you integrate these patterns into your projects, you will find that Zod not only catches errors before they reach your users but also serves as living documentation of exactly what your application expects from the world around it.