Practical Zod: Schema Validation That Makes Sense
Validating data in JavaScript is messy. You write the same checks repeatedly, types drift from runtime, and error messages are an afterthought. You’ve probably written code like this before:
if (typeof data.email !== "string") throw new Error("Email required");if (!data.email.includes("@")) throw new Error("Invalid email");if (typeof data.age !== "number") throw new Error("Age required");if (data.age < 0 || data.age > 120) throw new Error("Invalid age");// ... and on for every fieldIt’s repetitive and error-prone. Zod replaces all of this with declarative schemas that are easy to read, compose, and reuse. And if you’re using TypeScript, you get automatic type inference from your schemas.
This guide shows you how to use Zod effectively, from basic validation to advanced patterns.
Getting Started
npm install zodA schema describes what valid data looks like. You create one, then use it to validate data:
import { z } from "zod";
// Define what "valid" meansconst emailSchema = z.string().email();
// Validate data against itemailSchema.parse("alice@example.com"); // ✅ returns the stringemailSchema.parse("not-an-email"); // ❌ throws ZodError.parse() throws an error if validation fails. If you’d rather handle errors without try/catch, use .safeParse(). It returns a result object instead:
const result = emailSchema.safeParse("test@example.com");
if (result.success) { console.log(result.data); // the validated string} else { console.log(result.error); // ZodError with details}Throughout this guide, we’ll use both: .parse() when we expect valid data, .safeParse() when we need to handle errors gracefully.
1. Primitive Types
Every validation starts with a base type. Zod has schemas for all JavaScript primitives:
z.string() // validates stringsz.number() // validates numbersz.boolean() // validates booleansz.date() // validates Date objectsz.null() // validates nullz.undefined() // validates undefinedz.bigint() // validates BigIntThese just check the type. In the next sections, we’ll learn how to make them more useful by chaining constraints for length, format, patterns, and more.
String Validations
Strings need the most validation: length limits, format checks, pattern matching. Zod provides built-ins for common cases:
// How long should it be?z.string().min(2, "Too short")z.string().max(100, "Too long")z.string().length(5, "Must be exactly 5 characters")
// What format should it have?z.string().email("Invalid email")z.string().url("Invalid URL")z.string().uuid("Invalid UUID")z.string().datetime("Invalid ISO datetime")
// Does it match a pattern?z.string().regex(/^[A-Z]+$/, "Must be uppercase letters")z.string().startsWith("https://")z.string().endsWith(".com")z.string().includes("@")Chain multiple constraints for complex rules. They run in order, and the first failure stops validation:
const usernameSchema = z .string() .min(3, "Username must be at least 3 characters") .max(20, "Username must be 20 characters or less") .regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers, and underscores");
usernameSchema.parse("alice_123"); // ✅usernameSchema.parse("al"); // ❌ "Username must be at least 3 characters"usernameSchema.parse("Alice"); // ❌ "Only lowercase letters, numbers, and underscores"Number Validations
Numbers have their own constraints: integers, ranges, positive/negative:
z.number().int("Must be an integer")z.number().positive("Must be positive")z.number().negative("Must be negative")z.number().nonnegative("Must be 0 or greater")z.number().min(0, "Minimum is 0")z.number().max(100, "Maximum is 100")z.number().multipleOf(5, "Must be multiple of 5")A practical example: an e-commerce quantity field that must be a positive whole number:
const quantitySchema = z .number() .int("Must be a whole number") .positive("Must be at least 1");
quantitySchema.parse(5); // ✅quantitySchema.parse(0); // ❌ "Must be at least 1"quantitySchema.parse(2.5); // ❌ "Must be a whole number"Try it yourself:
2. Objects & Nested Data
Most real data isn’t a single string or number. It’s structured objects with multiple fields. Objects are where Zod really shines because you define the shape once and get both validation and TypeScript types automatically.
const userSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), age: z.number().optional(),});
// TypeScript infers this type automatically:// { id: number; name: string; email: string; age?: number }type User = z.infer<typeof userSchema>;The z.infer<> utility extracts the TypeScript type from your schema. This means you never have to manually write type definitions. Change the schema, and the types update automatically.
Optional, Nullable, and Default
Not every field is required. Zod gives you modifiers to handle missing or null values:
z.string().optional() // string | undefined (field can be missing)z.string().nullable() // string | null (field can be null)z.string().nullish() // string | null | undefined (either)z.string().default("fallback") // fills in a value if undefinedUse .optional() for fields users don’t have to fill in. Use .nullable() when your API explicitly returns null. Use .default() when you want sensible fallbacks applied during parsing.
Nested Objects
Real-world data is nested. An order has items, a user has addresses, a blog post has comments. Zod schemas compose naturally, so you can define smaller schemas and combine them:
const addressSchema = z.object({ street: z.string(), city: z.string(), country: z.string(), zipCode: z.string().optional(),});
const userSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), address: addressSchema, // required address billingAddress: addressSchema.optional(), // optional second address});Notice how addressSchema is reused for both address and billingAddress. This keeps your validation consistent. If you add a state field to addresses, both places get it.
Try building a user schema with nested address:
3. Collections
Beyond single objects, you’ll often need to validate lists, dictionaries, and other collections.
Arrays
Arrays validate that every item in the list matches the given schema. Use for tags, cart items, comments, etc:
const tagsSchema = z.array(z.string());tagsSchema.parse(["react", "typescript"]); // ✅tagsSchema.parse(["react", 42]); // ❌ all items must be strings
// Constrain the array itselfz.array(z.string()).min(1, "Need at least one")z.array(z.string()).max(5, "Maximum 5 allowed")z.array(z.string()).nonempty("Cannot be empty") // same as min(1)z.array(z.string()).length(3, "Must have exactly 3")Tuples
Unlike arrays where all elements have the same type, tuples have fixed positions with specific types. Use them for coordinates, CSV rows, or function return values:
// 2D coordinates - always [x, y]const pointSchema = z.tuple([z.number(), z.number()]);pointSchema.parse([10, 20]); // ✅ [number, number]
// RGB color - exactly 3 values, each 0-255const rgbSchema = z.tuple([ z.number().min(0).max(255), z.number().min(0).max(255), z.number().min(0).max(255),]);rgbSchema.parse([255, 128, 0]); // ✅ orange
// Mixed types - name and age pairconst nameAgeSchema = z.tuple([z.string(), z.number()]);nameAgeSchema.parse(["Alice", 30]); // ✅ [string, number]Records
Records validate objects with dynamic keys. Use whenever you’d write { [key: string]: value } in TypeScript:
const scoresSchema = z.record(z.string(), z.number());scoresSchema.parse({ alice: 100, bob: 85 }); // ✅scoresSchema.parse({ alice: "high" }); // ❌ value must be number
// Constrain both keys and valuesconst userAgesSchema = z.record( z.string().email(), // keys must be valid emails z.number().positive() // values must be positive numbers);userAgesSchema.parse({ "alice@example.com": 25 }); // ✅userAgesSchema.parse({ "not-an-email": 25 }); // ❌ invalid keyJavaScript object keys are always coerced to strings. { 1: "one" } and { "1": "one" } are the same. When you use z.number() as a key schema, Zod validates that the string key represents a valid number:
const numericKeysSchema = z.record(z.number(), z.string());numericKeysSchema.parse({ 1: "one", 2: "two" }); // ✅ keys become "1", "2"numericKeysSchema.parse({ "1.5": "x", "-3": "y" }); // ✅ decimals and negatives worknumericKeysSchema.parse({ abc: "nope" }); // ❌ "abc" is not a valid numberNeed numeric keys without coercion? Use Maps.
Maps
Maps validate ES6 Map collections. Unlike records, maps can have non-string keys and preserve insertion order:
// User ID (number) -> role mappingconst userRolesSchema = z.map(z.number(), z.enum(["admin", "user"]));userRolesSchema.parse(new Map([[1, "admin"], [2, "user"]])); // ✅userRolesSchema.parse(new Map([["1", "admin"]])); // ❌ key must be numberSets
Sets validate ES6 Set collections. Use when you need guaranteed unique values like selected IDs, tags, or permissions:
const tagsSchema = z.set(z.string()).min(1).max(10);tagsSchema.parse(new Set(["typescript", "zod"])); // ✅
// Duplicates are automatically removed by SettagsSchema.parse(new Set(["zod", "typescript", "zod"])); // ✅ Set(2) { "zod", "typescript" }4. Unions, Enums & Literals
Sometimes you need more than basic types. You need specific values, a fixed set of options, or one of several types.
Literals
Literals match one exact value. Use them for version strings, status codes, or discriminator fields:
const versionSchema = z.literal("v1");const trueSchema = z.literal(true);const answerSchema = z.literal(42);
versionSchema.parse("v1"); // ✅versionSchema.parse("v2"); // ❌ must be exactly "v1"Enums
Enums define a fixed set of allowed values. Use for status fields, roles, or categories:
const statusSchema = z.enum(["pending", "active", "cancelled"]);
statusSchema.parse("active"); // ✅statusSchema.parse("invalid"); // ❌
// Extract the typetype Status = z.infer<typeof statusSchema>;// → "pending" | "active" | "cancelled"
// Access the values programmaticallystatusSchema.options; // ["pending", "active", "cancelled"]statusSchema.enum.active; // "active" (with autocomplete!)If you already have a TypeScript enum defined, use z.nativeEnum():
enum Role { Admin = "admin", User = "user",}const roleSchema = z.nativeEnum(Role);Which to use? Prefer
z.enum()when starting fresh. It’s simpler and the values are easier to access. Usez.nativeEnum()only when integrating with existing TypeScript enums.
Unions
Unions let a value match one of several types. Use when a field can be different types, like IDs that can be strings or numbers:
// ID can be string or number (common in APIs)const idSchema = z.union([z.string(), z.number()]);idSchema.parse("abc-123"); // ✅idSchema.parse(42); // ✅idSchema.parse(true); // ❌
// Shorthand using .or()const idSchema2 = z.string().or(z.number());
// Accept UUID strings or numeric IDsconst userIdSchema = z.union([ z.string().uuid(), z.number().int().positive()]);userIdSchema.parse("550e8400-e29b-41d4-a716-446655440000"); // ✅userIdSchema.parse(12345); // ✅Discriminated Unions
Discriminated unions are for objects that share a “type” field identifying which variant they are. This is common in API responses, event systems, and state machines. The discriminator field must use z.literal() so Zod knows which schema to apply:
const apiResponseSchema = z.discriminatedUnion("status", [ z.object({ status: z.literal("success"), data: z.object({ id: z.number(), name: z.string() }), }), z.object({ status: z.literal("error"), code: z.number(), message: z.string(), }),]);
// TypeScript narrows the type based on statusconst result = apiResponseSchema.parse(response);if (result.status === "success") { console.log(result.data.name); // ✅ TypeScript knows data exists} else { console.log(result.message); // ✅ TypeScript knows message exists console.log(result.data); // ❌ Error: data doesn't exist on error type}This pattern is powerful because validation and type narrowing happen together. Check status once and TypeScript knows exactly what fields are available.
5. Transformations
So far we’ve only validated data, checking if it matches a shape. Transforms go further: they modify data during parsing. This is useful for normalizing user input, converting types, or calculating derived values.
Built-in String Transforms
Zod provides common string transforms out of the box:
z.string().trim() // " hello " → "hello"z.string().toLowerCase() // "HELLO" → "hello"z.string().toUpperCase() // "hello" → "HELLO"Custom Transforms
Use .transform() for custom conversions. The input and output types can differ:
// Parse date strings into Date objectsconst dateStringSchema = z.string().transform((str) => new Date(str));dateStringSchema.parse("2024-01-15"); // "2024-01-15" → Date object
// Split comma-separated values into arrayconst tagListSchema = z.string().transform((str) => str.split(",").map(s => s.trim()));tagListSchema.parse("react, vue, angular"); // → ["react", "vue", "angular"]
// Parse JSON stringsconst jsonStringSchema = z.string().transform((str) => JSON.parse(str));jsonStringSchema.parse('{"name":"Alice"}'); // → { name: "Alice" }Coercion
Form inputs and query params always arrive as strings. Rather than writing transforms manually, use z.coerce to automatically convert types:
z.coerce.string() // anything → String(x)z.coerce.number() // anything → Number(x)z.coerce.boolean() // anything → Boolean(x)z.coerce.date() // anything → new Date(x)z.coerce.bigint() // anything → BigInt(x)Warning:
z.coerce.boolean()uses JavaScript’sBoolean()constructor, so"false"becomestrue(non-empty strings are truthy). For string booleans in query params, use a custom transform instead:
// ❌ z.coerce.boolean() coerces "false" to true (non-empty string)z.coerce.boolean().parse("false"); // true
// ✅ Custom transform for string "true"/"false"const stringBooleanSchema = z .string() .transform((val) => val === "true");
stringBooleanSchema.parse("true"); // truestringBooleanSchema.parse("false"); // falsePipe: Transform Then Validate
What if you transform a string to a number but also need to validate the number? Use .pipe() to chain a second schema after the transform:
// Parse string to number, then validate it's positiveconst positiveIntSchema = z .string() .transform((val) => parseInt(val, 10)) .pipe(z.number().int().positive());
positiveIntSchema.parse("42"); // ✅ returns 42positiveIntSchema.parse("-5"); // ❌ "Number must be greater than 0"positiveIntSchema.parse("abc"); // ❌ NaN fails number validationThis is powerful for form fields: the input is a string, you transform it, then validate the result.
Try transformations in action:
6. Custom Validation
Built-in validators cover common cases, but sometimes you need custom rules: password strength, business logic, cross-field dependencies. That’s where refinements come in.
Basic Refinements
Use .refine() to add custom validation functions. Return true if valid, false if not:
const passwordSchema = z .string() .min(8) .refine((val) => /[A-Z]/.test(val), { message: "Must contain an uppercase letter", }) .refine((val) => /[0-9]/.test(val), { message: "Must contain a number", }) .refine((val) => /[!@#$%^&*]/.test(val), { message: "Must contain a special character", });Refinements run in order after the base schema validates. If min(8) fails, the refinements don’t run.
Cross-Field Validation
Some validations depend on multiple fields like password confirmation or date ranges. Apply .refine() to the whole object to access all fields:
const signupFormSchema = z .object({ password: z.string().min(8), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], // attach error to this specific field });The path option controls which field the error attaches to. This is important for form libraries that display per-field errors.
Async Validation
Need to check the database or call an API? Refinements can be async:
const uniqueEmailSchema = z .string() .email() .refine(async (email) => { const exists = await checkEmailExists(email); return !exists; }, "Email already registered");
// Must use parseAsync for async refinementsawait uniqueEmailSchema.parseAsync("new@example.com");Note: If any refinement is async, you must use
.parseAsync()or.safeParseAsync()instead of the sync versions.
SuperRefine for Complex Logic
Regular .refine() stops at the first failure. If you need to report multiple errors at once (like a password strength meter showing all unmet requirements), use .superRefine():
const passwordStrengthSchema = z.string().superRefine((val, ctx) => { if (val.length < 8) { ctx.addIssue({ code: z.ZodIssueCode.too_small, minimum: 8, type: "string", inclusive: true, message: "At least 8 characters required", }); }
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", }); }
if (!/[!@#$%^&*]/.test(val)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Must contain a special character", }); }});
// Returns ALL issues at once - great for showing all requirementsTry refinements with cross-field validation:
7. Error Handling
When validation fails, you need to handle it gracefully. We’ve seen parse and safeParse already. Let’s look at them in more detail.
parse() vs safeParse()
.parse() throws a ZodError on failure. Use it when invalid data is truly exceptional:
try { const user = userSchema.parse(input); // user is fully typed here} catch (error) { if (error instanceof z.ZodError) { console.log(error.errors); }}.safeParse() returns a result object. Use it when you expect invalid data and want to handle it gracefully:
const result = userSchema.safeParse(input);
if (result.success) { console.log(result.data); // typed data} else { console.log(result.error.errors); // array of issues}safeParse is usually better for user input. No try/catch, cleaner control flow, and easier to show field-level errors.
Error Structure
When validation fails, you get a ZodError. It contains an issues array of ZodIssue objects:
// ZodError contains an array of issuesinterface ZodError { issues: ZodIssue[];}
// Each issue tells you what went wrong and whereinterface ZodIssue { code: string; // error type (e.g., "too_small", "invalid_type") message: string; // human-readable message path: (string | number)[]; // path to the field (e.g., ["address", "city"])}Formatting Errors
Raw error arrays are awkward to work with. Zod provides two helper methods to restructure them:
const result = userSchema.safeParse(input);
if (!result.success) { // .format() - nested structure matching your schema shape const formatted = result.error.format(); // { // email: { _errors: ["Invalid email"] }, // address: { // city: { _errors: ["Required"] }, // _errors: [] // } // }
// .flatten() - flat structure, easier for simple forms const flat = result.error.flatten(); // { // formErrors: [], // root-level errors (from object refinements) // fieldErrors: { email: ["Invalid email"], "address.city": ["Required"] } // }}Use .format() when you need to navigate nested errors. Use .flatten() for simple forms where you just need a field-to-errors mapping.
Custom Error Messages
Zod’s default messages are technical (“Invalid type”). Override them with user-friendly text:
const emailSchema = z .string({ required_error: "Email is required" }) .email({ message: "Please enter a valid email" });Most validators accept a message option. Use it generously. Your users shouldn’t see “Expected string, received number”.
8. Type Inference
This is where Zod becomes essential for TypeScript. Instead of defining types separately from validation, you derive types from your schemas:
const userSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), role: z.enum(["admin", "user"]),});
// Infer the type from the schematype User = z.infer<typeof userSchema>;// → { id: number; name: string; email: string; role: "admin" | "user" }
// Use it everywherefunction createUser(data: User) { // data is fully typed}Add a field to the schema? The type updates automatically. Remove one? TypeScript catches every place you were using it. No more “forgot to update the type” bugs.
Input vs Output Types
When schemas include transforms, input and output types can be different. Zod tracks both:
const stringToNumberSchema = z.string().transform((v) => parseInt(v, 10));
type Input = z.input<typeof stringToNumberSchema>; // stringtype Output = z.output<typeof stringToNumberSchema>; // numbertype Inferred = z.infer<typeof stringToNumberSchema>; // number (same as output)This matters for form handling. Inputs are strings, but after parsing you want proper types:
const formSchema = z.object({ age: z.string().transform(v => parseInt(v, 10)), joinDate: z.string().transform(v => new Date(v)),});
type FormInput = z.input<typeof formSchema>;// { age: string; joinDate: string } - what the form sends
type FormOutput = z.output<typeof formSchema>;// { age: number; joinDate: Date } - what you work with after parsing9. Schema Composition
As your app grows, you’ll want to reuse and modify schemas. Zod provides methods like .extend(), .pick(), .omit(), and .partial() to derive new schemas from existing ones.
A common pattern is defining a base schema and deriving variations for different API operations:
// Base schema with all fieldsconst userSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), password: z.string().min(8), createdAt: z.date(),});
// POST /users - id and createdAt removedconst createUserSchema = userSchema.omit({ id: true, createdAt: true });
// PATCH /users/:id - id and createdAt removed, all fields optionalconst updateUserSchema = userSchema.omit({ id: true, createdAt: true }).partial();
// GET /users/:id - password removedconst userResponseSchema = userSchema.omit({ password: true });
// Type inference works for all derived schemastype CreateUser = z.infer<typeof createUserSchema>;type UpdateUser = z.infer<typeof updateUserSchema>;type UserResponse = z.infer<typeof userResponseSchema>;Branded Types
TypeScript uses structural typing. If two types have the same shape, they’re interchangeable. This can cause subtle bugs:
type UserId = string;type PostId = string;
function deleteUser(id: UserId) { /* ... */ }
const visitorId: UserId = "user_123";const pageId: PostId = "post_456";
deleteUser(pageId); // No type error, but a logic error!// We just tried to delete a user with a post IDBranded types add a compile-time tag that makes structurally identical types incompatible:
const userIdSchema = z.string().uuid().brand<"UserId">();const postIdSchema = z.string().uuid().brand<"PostId">();
type UserId = z.infer<typeof userIdSchema>;type PostId = z.infer<typeof postIdSchema>;
function getUser(id: UserId) { /* ... */ }function getPost(id: PostId) { /* ... */ }
const userId = userIdSchema.parse("123e4567-e89b-12d3-a456-426614174000");const postId = postIdSchema.parse("987fcdeb-51a2-3bc4-d567-426614174999");
getUser(userId); // ✅getUser(postId); // ❌ TypeScript error - can't use PostId as UserIdUse branded types when mixing up IDs or values would cause serious bugs, like in database operations, payments, or permissions.
10. Real-World Use Cases
We’ve learned the concepts. Now let’s see where to use Zod in production.
Form Validation
Zod integrates with React Hook Form, Formik, and other form libraries via resolver packages:
import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";
const formSchema = z.object({ email: z.string().email(), password: z.string().min(8),});
const { register, handleSubmit } = useForm({ resolver: zodResolver(formSchema),});API Response Validation
External APIs change without warning. Parse responses to catch breaking changes at the boundary instead of deep in your code:
const userResponseSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(),});
async function fetchUser(id: number) { const res = await fetch(`/api/users/${id}`); const data = await res.json(); return userResponseSchema.parse(data); // fails fast if API shape changes}Environment Variables
process.env values are always string | undefined. Validate at startup to fail fast with clear errors:
const envSchema = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(["development", "production", "test"]), API_KEY: z.string().min(1, "API_KEY is required"),});
// Fail immediately at startup if env is misconfiguredexport const env = envSchema.parse(process.env);URL Query Parameters
Query params are always strings. Use coercion and defaults for type-safe URL parsing:
const searchParamsSchema = z.object({ page: z.coerce.number().positive().default(1), limit: z.coerce.number().min(1).max(100).default(20), sort: z.enum(["asc", "desc"]).default("desc"), search: z.string().optional(),});
// /products?page=2&sort=ascconst url = new URL("https://example.com/products?page=2&sort=asc");const rawParams = Object.fromEntries(url.searchParams);const result = searchParamsSchema.safeParse(rawParams);
if (result.success) { console.log(result.data); // { page: 2, limit: 20, sort: "asc", search: undefined }}Backend Validation
Express: validate request bodies before your route logic:
const createUserSchema = z.object({ name: z.string().min(2), email: z.string().email(),});
app.post("/users", async (req, res) => { const result = createUserSchema.safeParse(req.body); if (!result.success) { return res.status(400).json({ errors: result.error.flatten() }); } // result.data is typed as { name: string; email: string } const user = await db.user.create({ data: result.data }); res.json(user);});Next.js Server Actions: type-safe form handling:
"use server";
const contactSchema = z.object({ email: z.string().email(), message: z.string().min(10).max(1000),});
export async function submitContact(formData: FormData) { const result = contactSchema.safeParse({ email: formData.get("email"), message: formData.get("message"), }); if (!result.success) { return { error: result.error.flatten() }; } await db.contact.create({ data: result.data }); return { success: true };}tRPC: end-to-end type safety from client to server:
const createUserInputSchema = z.object({ name: z.string().min(2), email: z.string().email(),});
const createUser = publicProcedure .input(createUserInputSchema) // if invalid → throws TRPCError BAD_REQUEST .mutation(async ({ input }) => { // input is typed as { name: string; email: string } return db.user.create({ data: input }); });Try It Yourself
A React signup form demonstrating key Zod features with live validation:
- String validations: email format, min length
- Transforms: trim whitespace, lowercase email
- Cross-field validation: password confirmation
- Type inference: form data is fully typed
This form validates on submit only to keep the example simple. In production, you’d typically validate on blur or use a form library like React Hook Form.
Key Takeaways
- Define once, use everywhere. Schemas give you validation AND TypeScript types from the same source
- Use
safeParseoverparse. Cleaner error handling without try/catch - Chain validators for complex rules:
.string().email().min(5).max(100) - Transforms normalize data during parsing: trim, lowercase, parse dates, calculate totals
- Refinements handle custom logic: password strength, cross-field validation
- Coercion for external data: form inputs, query params, JSON payloads
- Discriminated unions for type-safe branching on tagged objects
- Compose schemas with
.extend(),.pick(),.omit(),.partial(),.merge() - Branded types for nominal typing when you need extra compile-time safety
Zod removes the friction from validation. Your schemas are readable, your types stay in sync, and your error messages make sense. Start with simple schemas and add complexity only when you need it.
For more patterns, check the official Zod documentation.
← Back to blog