Practical Zod: Schema Validation That Makes Sense Practical Zod: Schema Validation That Makes Sense

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 field

It’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

Terminal window
npm install zod

A schema describes what valid data looks like. You create one, then use it to validate data:

import { z } from "zod";
// Define what "valid" means
const emailSchema = z.string().email();
// Validate data against it
emailSchema.parse("alice@example.com"); // ✅ returns the string
emailSchema.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 strings
z.number() // validates numbers
z.boolean() // validates booleans
z.date() // validates Date objects
z.null() // validates null
z.undefined() // validates undefined
z.bigint() // validates BigInt

These 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 undefined

Use .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 itself
z.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-255
const 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 pair
const 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 values
const 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 key

JavaScript 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 work
numericKeysSchema.parse({ abc: "nope" }); // ❌ "abc" is not a valid number

Need 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 mapping
const 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 number

Sets

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 Set
tagsSchema.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 type
type Status = z.infer<typeof statusSchema>;
// → "pending" | "active" | "cancelled"
// Access the values programmatically
statusSchema.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. Use z.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 IDs
const 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 status
const 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 objects
const dateStringSchema = z.string().transform((str) => new Date(str));
dateStringSchema.parse("2024-01-15"); // "2024-01-15" → Date object
// Split comma-separated values into array
const tagListSchema = z.string().transform((str) => str.split(",").map(s => s.trim()));
tagListSchema.parse("react, vue, angular"); // → ["react", "vue", "angular"]
// Parse JSON strings
const 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’s Boolean() constructor, so "false" becomes true (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"); // true
stringBooleanSchema.parse("false"); // false

Pipe: 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 positive
const positiveIntSchema = z
.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().int().positive());
positiveIntSchema.parse("42"); // ✅ returns 42
positiveIntSchema.parse("-5"); // ❌ "Number must be greater than 0"
positiveIntSchema.parse("abc"); // ❌ NaN fails number validation

This 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 refinements
await 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 requirements

Try 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 issues
interface ZodError {
issues: ZodIssue[];
}
// Each issue tells you what went wrong and where
interface 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 schema
type User = z.infer<typeof userSchema>;
// → { id: number; name: string; email: string; role: "admin" | "user" }
// Use it everywhere
function 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>; // string
type Output = z.output<typeof stringToNumberSchema>; // number
type 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 parsing

9. 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 fields
const 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 removed
const createUserSchema = userSchema.omit({ id: true, createdAt: true });
// PATCH /users/:id - id and createdAt removed, all fields optional
const updateUserSchema = userSchema.omit({ id: true, createdAt: true }).partial();
// GET /users/:id - password removed
const userResponseSchema = userSchema.omit({ password: true });
// Type inference works for all derived schemas
type 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 ID

Branded 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 UserId

Use 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 misconfigured
export 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=asc
const 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 safeParse over parse. 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