All posts

Advanced Zod: Designing Complex Validation Schemas

15 min read

Custom Validation Functions

We have talked a little bit about writing custom validation rules in the previous article, Intermediate Zod: Validation Workflows and Techniques. Using a custom validation function in Zod allows you to create sophisticated validation logic that goes beyond simple type checking and built-in validators.

By using methods like .refine() and .superRefine(), you can implement complex business rules, cross-field validations, and even asynchronous validation workflows. These custom functions give you the flexibility to validate data against any arbitrary condition while maintaining type safety and integration with Zod's error handling system. The choice between .refine() and .superRefine() depends on your validation needs.

Refine

Use .refine() for simpler validations where you need to check a single condition and return a boolean result. It's perfect for straightforward validations like age checks or string pattern matching.

Example
ts
// Using refine for simple custom validation const ageSchema = z.number().refine((age) => age >= 18, { message: "Must be 18 or older", });

SuperRefine

On the other hand, .superRefine() gives you more granular control over the validation process - use it when you need to perform multiple related validations, add custom error messages with specific paths, or handle complex cross-field validations.

The context object provided by .superRefine() allows you to manually add validation issues and specify exactly which fields failed validation, making it ideal for sophisticated validation scenarios.

Example
ts
// Using superRefine for complex validation with custom error paths const userSchema = z .object({ email: z.string().email(), password: z.string(), confirmPassword: z.string(), }) .superRefine((data, ctx) => { if (data.password !== data.confirmPassword) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Passwords don't match", path: ["confirmPassword"], // Specify exact field with error }); } // Multiple validations in one superRefine if (data.password.includes(data.email.split("@")[0])) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Password cannot contain email username", path: ["password"], }); } });

Conditional Validation

Conditional validation allows you to apply different validation rules based on the value of a field or a combination of fields. This is useful for creating dynamic validation logic that adapts to the user's input.

Example

ts
const paymentSchema = z .object({ paymentType: z.enum(["credit", "bank"]), creditCard: z.string().optional(), cardExpiry: z.string().optional(), cardCVC: z.string().optional(), bankAccount: z.string().optional(), institutionNumber: z.string().optional(), transitNumber: z.string().optional(), }) .refine( (data) => { if (data.paymentType === "credit") { return data.creditCard && data.cardExpiry && data.cardCVC; } if (data.paymentType === "bank") { return data.bankAccount && data.institutionNumber && data.transitNumber; } return false; }, { message: "Required payment fields missing", path: ["paymentType"], } );

Demo

Unions & Intersections

Zod provides powerful union and intersection types that allow you to combine schemas in flexible ways. This is particularly useful when dealing with complex data structures or when you need to validate data that can take different shapes based on certain conditions.

Union

Unions (z.union()) let you specify that a value must match one of several schemas. For example, you might have employees and contractors who share some common attributes but also have type-specific fields. Using a union allows you to enforce strict validation rules for each type while maintaining type safety and clear error messages.

Example
ts
const userSchema = z.union([ z.object({ type: z.literal("employee"), employeeId: z.string(), department: z.string(), }), z.object({ type: z.literal("contractor"), contractorId: z.string(), company: z.string(), }), ]); // Valid examples: const employee = { type: "employee", employeeId: "E123", department: "Engineering", }; const contractor = { type: "contractor", contractorId: "C456", company: "Acme Corp", };
Demo

Intersection

While intersections (z.intersection()) require a value to satisfy multiple schemas simultaneously. For example, you might have a user who is both an employee and a contractor. Using an intersection allows you to enforce strict validation rules for each type while maintaining type safety and clear error messages.

Example
ts
const employeeSchema = z.object({ employeeId: z.string(), department: z.string(), }); const contractorSchema = z.object({ contractorId: z.string(), company: z.string(), }); const dualRoleUserSchema = z.intersection(employeeSchema, contractorSchema); // Valid example: const dualRoleUser = { employeeId: "E123", department: "Engineering", contractorId: "C456", company: "Acme Corp", };
Demo

Dynamic Schema generation

Zod provides powerful capabilities for dynamically generating schemas at runtime. This allows you to create flexible validation rules that can adapt based on runtime conditions, user input, or external configuration. You can programmatically combine schema primitives, add or remove fields, and modify validation rules. This is particularly useful when building dynamic forms, handling varying API responses, or implementing feature flags that affect data validation requirements.

Example

ts
// Function to generate a schema based on user role function generateUserSchema(role: "admin" | "user") { // Base schema that all users share const baseSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(2), }); // Additional fields for admin users if (role === "admin") { return baseSchema.extend({ accessLevel: z.number().min(1).max(5), managedDepartments: z.array(z.string()), canDeleteUsers: z.boolean(), }); } // Additional fields for regular users return baseSchema.extend({ department: z.string(), joinDate: z.date(), }); } // Example usage: const adminSchema = generateUserSchema("admin"); const userSchema = generateUserSchema("user"); // Valid admin example: const adminUser = { id: "123e4567-e89b-12d3-a456-426614174000", email: "admin@example.com", name: "Admin User", accessLevel: 3, managedDepartments: ["Engineering", "Design"], canDeleteUsers: true, }; // Valid regular user example: const regularUser = { id: "987fcdeb-51a2-43d7-9b56-312214174000", email: "user@example.com", name: "Regular User", department: "Engineering", joinDate: new Date(), };

Demo


Throughout this series, we've explored Zod's powerful validation capabilities, from basic primitives to advanced schema designs. We've covered:

  • Core concepts and primitive types
  • Schema constraints and composition
  • Custom validation rules and transformations
  • Complex validation patterns using unions and intersections
  • Dynamic schema generation

Zod provides a robust, type-safe foundation for data validation in TypeScript applications. Its declarative API and comprehensive feature set make it an excellent choice for:

  • Form validation
  • API request/response validation
  • Configuration validation
  • Runtime type checking
  • Schema-driven development

By leveraging Zod's features effectively, you can build more reliable applications with strong runtime guarantees while maintaining excellent developer experience through TypeScript integration.

The examples in this series serve as a starting point - Zod offers many more advanced features and patterns to explore as your validation needs grow more complex.


  • Zod
  • Schema Validation
  • TypeScript
  • Advanced
Made with ❤️ in 🇨🇦 · Copyright © 2025 Valentin Prugnaud
Foxy seeing you here! Let's chat!
Logo