Zod Validation
This skill covers Zod v3+ patterns for building type-safe validation schemas for forms, APIs, and data parsing.
Core Concepts
Zod Benefits:
-
TypeScript-first schema validation
-
Runtime type checking
-
Type inference from schemas
-
Composable and reusable schemas
-
Rich error messages
-
Zero dependencies
Key Terms:
-
Schema: Validation definition (z.object, z.string, etc.)
-
Parse: Validate and return typed data
-
SafeParse: Validate without throwing errors
-
Transform: Convert data after validation
-
Refine: Custom validation logic
Installation
npm install zod
Basic Types
Primitives
import { z } from "zod";
// String const nameSchema = z.string(); const emailSchema = z.string().email(); const urlSchema = z.string().url(); const uuidSchema = z.string().uuid();
// Number const ageSchema = z.number(); const priceSchema = z.number().positive(); const scoreSchema = z.number().min(0).max(100); const integerSchema = z.number().int();
// Boolean const agreedSchema = z.boolean();
// Date const birthDateSchema = z.date(); const futureSchema = z.date().min(new Date());
// BigInt const bigNumSchema = z.bigint();
// Symbol const symSchema = z.symbol();
// Undefined, Null, Void const undefinedSchema = z.undefined(); const nullSchema = z.null(); const voidSchema = z.void();
// Any, Unknown, Never const anySchema = z.any(); // ⚠️ Avoid if possible const unknownSchema = z.unknown(); const neverSchema = z.never();
String Validations
const schema = z.string() .min(3, "Must be at least 3 characters") .max(50, "Must be at most 50 characters") .email("Invalid email address") .url("Invalid URL") .uuid("Invalid UUID") .regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers, and underscores") .startsWith("https://", "Must start with https://") .endsWith(".com", "Must end with .com") .includes("@", "Must include @") .trim() // Remove whitespace .toLowerCase() // Convert to lowercase .toUpperCase(); // Convert to uppercase
// Custom error messages const emailSchema = z.string().email({ message: "Please enter a valid email address", });
// Length const exactLength = z.string().length(5, "Must be exactly 5 characters");
// Date strings const dateString = z.string().datetime(); // ISO 8601 const dateOnly = z.string().date(); // YYYY-MM-DD const timeOnly = z.string().time(); // HH:mm:ss
// IP addresses const ipv4 = z.string().ip({ version: "v4" }); const ipv6 = z.string().ip({ version: "v6" });
Number Validations
const schema = z.number() .min(0, "Must be at least 0") .max(100, "Must be at most 100") .positive("Must be positive") .negative("Must be negative") .nonnegative("Must be 0 or greater") .nonpositive("Must be 0 or less") .int("Must be an integer") .multipleOf(5, "Must be a multiple of 5") .finite("Must be finite") .safe("Must be a safe integer");
// Coerce string to number const coercedNumber = z.coerce.number(); // "123" → 123
Object Schemas
Basic Objects
const userSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1), age: z.number().int().min(0).max(120), verified: z.boolean(), role: z.enum(["ADMIN", "USER", "GUEST"]), createdAt: z.date(), });
// Type inference type User = z.infer<typeof userSchema>; // { // id: string; // email: string; // name: string; // age: number; // verified: boolean; // role: "ADMIN" | "USER" | "GUEST"; // createdAt: Date; // }
Optional and Nullable
const schema = z.object({ // Optional (can be undefined) bio: z.string().optional(),
// Nullable (can be null) avatar: z.string().url().nullable(),
// Nullable and optional (can be null or undefined) middleName: z.string().nullable().optional(),
// With default value role: z.string().default("USER"),
// Default from function createdAt: z.date().default(() => new Date()), });
Nested Objects
const addressSchema = z.object({ street: z.string(), city: z.string(), state: z.string().length(2), zipCode: z.string().regex(/^\d{5}$/), });
const userSchema = z.object({ name: z.string(), email: z.string().email(), address: addressSchema, // Or inline settings: z.object({ notifications: z.boolean(), theme: z.enum(["light", "dark"]), }), });
Partial, Required, Pick, Omit
const userSchema = z.object({ id: z.string(), email: z.string().email(), name: z.string(), bio: z.string().optional(), });
// Make all fields optional const partialUser = userSchema.partial(); type PartialUser = z.infer<typeof partialUser>; // { id?: string; email?: string; name?: string; bio?: string }
// Make all fields required (remove optional) const requiredUser = userSchema.required();
// Pick specific fields const userCredentials = userSchema.pick({ email: true, password: true });
// Omit fields const publicUser = userSchema.omit({ password: true });
// Make specific fields optional const updateSchema = userSchema.partial({ bio: true });
Extend and Merge
const baseSchema = z.object({ id: z.string(), createdAt: z.date(), });
// Extend (adds fields) const userSchema = baseSchema.extend({ email: z.string().email(), name: z.string(), });
// Merge (combines schemas) const timestampSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), });
const fullSchema = userSchema.merge(timestampSchema);
Arrays and Tuples
Arrays
// Array of strings const tagsSchema = z.array(z.string());
// Array with constraints const schema = z.array(z.string()) .min(1, "At least one tag required") .max(5, "Maximum 5 tags allowed") .nonempty("Array cannot be empty");
// Array of objects const usersSchema = z.array( z.object({ id: z.string(), name: z.string(), }) );
// Type inference type Users = z.infer<typeof usersSchema>; // Array<{ id: string; name: string }>
Tuples
// Fixed-length array with specific types const coordinatesSchema = z.tuple([ z.number(), // latitude z.number(), // longitude ]);
type Coordinates = z.infer<typeof coordinatesSchema>; // [number, number]
// With rest parameters const mixedSchema = z.tuple([ z.string(), // first element is string z.number(), // second element is number ]).rest(z.boolean()); // rest are booleans
// ["hello", 42, true, false, true]
Enums and Literals
Enums
// Native enum const schema = z.enum(["ADMIN", "USER", "GUEST"]);
type Role = z.infer<typeof schema>; // "ADMIN" | "USER" | "GUEST"
// Access enum values schema.enum.ADMIN; // "ADMIN" schema.options; // ["ADMIN", "USER", "GUEST"]
// TypeScript enum enum UserRole { ADMIN = "ADMIN", USER = "USER", GUEST = "GUEST", }
const roleSchema = z.nativeEnum(UserRole);
Literals
// Single literal value const trueSchema = z.literal(true); const adminSchema = z.literal("ADMIN"); const numberSchema = z.literal(42);
// Use with union for multiple values const statusSchema = z.union([ z.literal("pending"), z.literal("approved"), z.literal("rejected"), ]);
// Or use enum const statusEnum = z.enum(["pending", "approved", "rejected"]);
Unions and Discriminated Unions
Basic Unions
// String or number const idSchema = z.union([z.string(), z.number()]);
// Null or string const nullableString = z.union([z.string(), z.null()]); // Or use .nullable() const nullableString2 = z.string().nullable();
Discriminated Unions
// Better type inference for unions const eventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("click"), x: z.number(), y: z.number(), }), z.object({ type: z.literal("keypress"), key: z.string(), }), z.object({ type: z.literal("focus"), element: z.string(), }), ]);
type Event = z.infer<typeof eventSchema>; // { type: "click"; x: number; y: number } // | { type: "keypress"; key: string } // | { type: "focus"; element: string }
// TypeScript knows which fields are available based on type function handleEvent(event: Event) { if (event.type === "click") { console.log(event.x, event.y); // TypeScript knows x and y exist } else if (event.type === "keypress") { console.log(event.key); // TypeScript knows key exists } }
Custom Validation
Refine
// Single refinement const passwordSchema = z .string() .min(8) .refine( (password) => /[A-Z]/.test(password), { message: "Password must contain an uppercase letter" } ) .refine( (password) => /[a-z]/.test(password), { message: "Password must contain a lowercase letter" } ) .refine( (password) => /[0-9]/.test(password), { message: "Password must contain a number" } );
// Multi-field refinement const signupSchema = z .object({ password: z.string().min(8), confirmPassword: z.string(), }) .refine( (data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], // Error attached to this field } );
// Async refinement const emailSchema = z.string().email().refine( async (email) => { const user = await db.user.findUnique({ where: { email } }); return !user; // true if email is available }, { message: "Email already registered" } );
Transform
// Transform after validation const trimmedString = z.string().transform((str) => str.trim());
const numberFromString = z.string().transform((str) => parseInt(str, 10));
// Or use coerce const coercedNumber = z.coerce.number(); // "123" → 123
// Complex transformation const userSchema = z .object({ email: z.string().email(), name: z.string(), }) .transform((data) => ({ ...data, email: data.email.toLowerCase(), displayName: data.name.toUpperCase(), }));
// Async transform const uploadSchema = z .instanceof(File) .transform(async (file) => { const url = await uploadToS3(file); return { url, size: file.size }; });
Superrefine (Advanced)
const schema = z.object({ age: z.number(), hasGuardian: z.boolean(), guardianName: z.string().optional(), }).superRefine((data, ctx) => { if (data.age < 18 && !data.hasGuardian) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Guardian required for users under 18", path: ["hasGuardian"], }); }
if (data.hasGuardian && !data.guardianName) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Guardian name is required", path: ["guardianName"], }); } });
Parsing and Validation
Parse (Throws on Error)
const userSchema = z.object({ email: z.string().email(), age: z.number(), });
try { const user = userSchema.parse({ email: "user@example.com", age: 25, }); // user is typed as { email: string; age: number } } catch (error) { if (error instanceof z.ZodError) { console.error(error.errors); } }
SafeParse (Returns Result Object)
const result = userSchema.safeParse({ email: "invalid", age: "not a number", });
if (result.success) { const user = result.data; // user is typed correctly } else { const errors = result.error.errors; // [ // { // code: "invalid_string", // message: "Invalid email", // path: ["email"], // }, // { // code: "invalid_type", // message: "Expected number, received string", // path: ["age"], // } // ] }
Async Parsing
// For async transforms or refinements const result = await schema.parseAsync(data); const result = await schema.safeParseAsync(data);
Error Handling
Error Structure
try { userSchema.parse(invalidData); } catch (error) { if (error instanceof z.ZodError) { // error.errors is an array of issues error.errors.forEach((issue) => { console.log(issue.path); // ["email"] console.log(issue.message); // "Invalid email" console.log(issue.code); // "invalid_string" });
// Formatted errors
const formatted = error.format();
// {
// email: { _errors: ["Invalid email"] },
// age: { _errors: ["Expected number, received string"] }
// }
// Flattened errors
const flattened = error.flatten();
// {
// formErrors: [],
// fieldErrors: {
// email: ["Invalid email"],
// age: ["Expected number, received string"]
// }
// }
} }
Custom Error Messages
const schema = z.object({ email: z.string({ required_error: "Email is required", invalid_type_error: "Email must be a string", }).email("Please enter a valid email address"),
age: z.number({ required_error: "Age is required", invalid_type_error: "Age must be a number", }).min(18, "Must be at least 18 years old"), });
// Global error map z.setErrorMap((issue, ctx) => { if (issue.code === z.ZodIssueCode.invalid_type) { if (issue.expected === "string") { return { message: "This field must be text" }; } } return { message: ctx.defaultError }; });
Reusable Schemas
Schema Composition
// Base schemas const emailSchema = z.string().email(); const passwordSchema = z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/); const timestampSchema = z.object({ createdAt: z.date().default(() => new Date()), updatedAt: z.date().default(() => new Date()), });
// Compose into larger schemas const loginSchema = z.object({ email: emailSchema, password: passwordSchema, });
const userSchema = z.object({ id: z.string().uuid(), email: emailSchema, name: z.string(), }).merge(timestampSchema);
Schema Factory
// Generic pagination schema function paginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) { return z.object({ items: z.array(itemSchema), total: z.number(), page: z.number(), pageSize: z.number(), }); }
// Usage const userSchema = z.object({ id: z.string(), name: z.string() }); const paginatedUsers = paginatedSchema(userSchema);
type PaginatedUsers = z.infer<typeof paginatedUsers>; // { // items: Array<{ id: string; name: string }>; // total: number; // page: number; // pageSize: number; // }
Shared Validation Schemas
// packages/core/src/schemas.ts export const emailSchema = z.string().email(); export const phoneSchema = z.string().regex(/^+?[1-9]\d{1,14}$/); export const uuidSchema = z.string().uuid();
export const paginationSchema = z.object({ page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(10), });
export const userCreateSchema = z.object({ email: emailSchema, name: z.string().min(1).max(100), phone: phoneSchema.optional(), });
export const userUpdateSchema = userCreateSchema.partial();
// Use in multiple packages // packages/functions/src/user/router.ts import { userCreateSchema } from "@myapp/core";
export const userRouter = router({ create: protectedProcedure .input(userCreateSchema) .mutation(async ({ input }) => { // input is typed from schema }), });
Advanced Patterns
Branded Types
// Create nominal types const userId = z.string().uuid().brand("UserId"); const email = z.string().email().brand("Email");
type UserId = z.infer<typeof userId>; // string & { __brand: "UserId" } type Email = z.infer<typeof email>; // string & { __brand: "Email" }
// Prevents mixing different string types function getUser(id: UserId) { /* ... */ }
const validId = userId.parse("550e8400-e29b-41d4-a716-446655440000"); getUser(validId); // ✓ OK
const regularString = "550e8400-e29b-41d4-a716-446655440000"; getUser(regularString); // ✗ Type error
Lazy Schemas (Recursive)
// For recursive types type Category = { name: string; subcategories: Category[]; };
const categorySchema: z.ZodType<Category> = z.lazy(() => z.object({ name: z.string(), subcategories: z.array(categorySchema), }) );
Catch (Fallback Values)
// Provide fallback on parse failure const schema = z.string().catch("default value");
const result = schema.parse(123); // "default value"
// With function const dateSchema = z.date().catch(() => new Date());
Pipe (Chain Schemas)
// Parse then transform const schema = z.string().pipe(z.coerce.number());
const result = schema.parse("123"); // 123 (number)
// Multi-step validation const trimmedEmail = z .string() .transform((s) => s.trim()) .pipe(z.string().email());
Integration Patterns
With Conform (Forms)
import { parseWithZod } from "@conform-to/zod";
const schema = z.object({ email: z.string().email(), password: z.string().min(8), });
export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") { return json({ submission: submission.reply() }); }
// submission.value is typed from schema await login(submission.value); return redirect("/dashboard"); }
With tRPC
import { router, publicProcedure } from "./trpc"; import { z } from "zod";
const userRouter = router({ create: publicProcedure .input( z.object({ email: z.string().email(), name: z.string().min(1), }) ) .output( z.object({ id: z.string(), email: z.string(), name: z.string(), }) ) .mutation(async ({ input }) => { // input is typed return await createUser(input); }), });
With Prisma
import { Prisma } from "@prisma/client";
// Validate before database operation const userCreateSchema = z.object({ email: z.string().email(), name: z.string(), }) satisfies z.ZodType<Prisma.UserCreateInput>;
Best Practices
-
Reuse schemas: Create shared schemas in core package
-
Type from schemas: Use z.infer instead of duplicating types
-
Composable schemas: Build complex schemas from simple ones
-
Async sparingly: Use async refinements only on server
-
Custom errors: Provide helpful, user-friendly error messages
-
Transform carefully: Keep transformations simple and predictable
-
Test schemas: Unit test complex validation logic
-
Use safeParse: Prefer safeParse over parse to avoid exceptions
-
Branded types: Use for nominal typing when needed
-
Document schemas: Add JSDoc comments to complex schemas
Common Gotcas
-
Parse vs safeParse: parse throws, safeParse returns result
-
Optional vs nullable: optional = undefined, nullable = null
-
Async transforms: Must use parseAsync/safeParseAsync
-
Transform order: Transforms run after validation
-
Error paths: Use path in refine for specific field errors
-
Coerce vs transform: coerce is simpler for type conversion
-
Default values: Apply before validation
-
Array validation: Min/max checks array length, not items
-
Union types: Discriminated unions have better inference
-
File validation: Use z.instanceof(File), not z.object()
Resources
-
Zod Documentation
-
Zod GitHub
-
TypeScript Handbook