Conditional Zod Validation: 5 Efficient Patterns for Complex TypeScript Schemas
Use .refine for simple boolean checks between fields, .superRefine for granular error control, and z.discriminatedUnion when conditional values dictate entirely different object shapes, ensuring single-pass validation with full TypeScript inference.
Conditional Zod validation enables you to enforce data integrity rules that depend on interrelated field values within complex schemas. When working with the microsoft/TypeScript repository or similar large-scale codebases, maintaining readable, type-safe validation logic is critical for long-term maintainability. This guide explores the most efficient patterns for implementing conditional Zod validation based on another field's value, drawing architectural parallels from TypeScript's own type-checking implementation.
Pattern 1: Cross-Field Validation with .refine and .superRefine
The most direct approach to conditional Zod validation involves using .refine for simple predicates or .superRefine when you need detailed error context. Both methods receive the entire parsed object, allowing you to compare fields directly in a single pass.
Simple Boolean Checks with .refine
Use .refine when you need a single validation rule that returns a boolean. This pattern is ideal for constraints like ensuring one field exceeds another or enforcing required fields based on a type discriminator.
import { z } from "zod";
const SimpleSchema = z.object({
type: z.enum(["A", "B"]),
value: z.number(),
}).refine(
data => data.type !== "A" || data.value > 0,
{ message: "`value` must be > 0 when `type` is 'A'", path: ["value"] }
);
Granular Error Control with .superRefine
When your conditional logic requires multiple, specific error messages or needs to access the Zod context, use .superRefine. This method provides the ctx object for adding issues with precise paths, similar to how src/compiler/utilities.ts handles detailed diagnostic reporting in the TypeScript compiler.
const SuperSchema = z.object({
mode: z.enum(["single", "range"]),
start: z.number().optional(),
end: z.number().optional(),
}).superRefine((data, ctx) => {
if (data.mode === "single") {
if (data.start == null) ctx.addIssue({
code: "custom",
message: "`start` required for single mode",
path: ["start"]
});
} else {
if (data.start == null) ctx.addIssue({
code: "custom",
message: "`start` required for range mode",
path: ["start"]
});
if (data.end == null) ctx.addIssue({
code: "custom",
message: "`end` required for range mode",
path: ["end"]
});
if (data.start != null && data.end != null && data.start >= data.end) {
ctx.addIssue({
code: "custom",
message: "`start` must be less than `end`",
path: ["start"]
});
}
}
});
Pattern 2: Structural Branching with z.discriminatedUnion
When conditional Zod validation requires entirely different object shapes based on a discriminator field, use z.discriminatedUnion. This approach is more efficient than nested if/else logic inside a refine function because Zod can determine the correct schema branch in a single pass, similar to how the TypeScript compiler discriminates between union types in src/compiler/types.ts.
const ActionSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("create"),
payload: z.object({
name: z.string(),
age: z.number().int().positive()
})
}),
z.object({
action: z.literal("delete"),
payload: z.object({
id: z.string().uuid()
})
})
]);
Pattern 3: Data Transformation and Piping with .transform and .pipe
For conditional Zod validation that requires data coercion before checking constraints, chain .transform with .pipe. This pattern allows you to normalize input values—such as parsing stringified numbers—before applying conditional logic in the piped schema, ensuring accurate data integrity checks.
const CoerceSchema = z
.object({
flag: z.string()
})
.transform(data => ({
...data,
flag: data.flag === "true"
}))
.pipe(
z.object({
flag: z.boolean(),
}).refine(d => !d.flag || (d as any).detail != null, {
message: "`detail` required when flag is true",
path: ["detail"]
})
);
Pattern 4: Conditional Schema Extension with .extend
When your conditional Zod validation requires adding specific fields only under certain conditions, use .extend within a refinement context. This pattern builds upon a base schema and dynamically appends fields based on runtime values, maintaining type safety while avoiding schema duplication.
This approach is particularly useful when validating configuration objects where optional feature flags enable additional properties, similar to how TypeScript handles optional members in object types defined in src/compiler/types.ts.
Pattern 5: Recursive Conditional Schemas with z.lazy
For advanced conditional Zod validation involving nested or polymorphic structures that reference themselves, use z.lazy with a custom factory function. This pattern allows the schema shape to depend on runtime values while handling recursive definitions, essential for validating tree-like data or deeply nested JSON structures.
As implemented in complex type systems like the TypeScript compiler according to src/compiler/utilities.ts, recursive validation ensures that nested nodes conform to context-specific constraints without infinite instantiation loops.
TypeScript Implementation Insights
The microsoft/TypeScript repository demonstrates architectural patterns that align closely with Zod's validation philosophy. In src/compiler/utilities.ts, the compiler employs helper functions similar to .refine and .superRefine to perform complex diagnostic checks across multiple AST nodes. The discriminated union pattern mirrors how TypeScript narrows types based on discriminant properties in src/compiler/types.ts.
When implementing conditional Zod validation in large-scale applications, follow the TypeScript compiler's approach of separating base type definitions from validation logic, as seen in src/harness/util.ts. This separation ensures that your schemas remain readable while maintaining rigorous data integrity checks across interdependent fields. Refer to the project README.md for additional context on how TypeScript manages complex type relationships that inform schema design.
Summary
.refineprovides the most readable solution for simple boolean conditions between fields, while.superRefineoffers granular error control for complex validation scenarios.z.discriminatedUniondelivers optimal performance when conditional values require entirely different object structures, eliminating the need for nested conditional logic..transformand.pipeenable pre-validation data coercion, ensuring that conditional checks operate on normalized values..extendandz.lazyhandle advanced cases involving dynamic field addition and recursive polymorphic structures.- All patterns execute in a single parse pass, preserving TypeScript type inference and aggregating errors for improved user experience.
Frequently Asked Questions
How do I validate that one field is greater than another in Zod?
Use the .refine method on your base schema to access both fields simultaneously. Pass a validation function that compares the values and returns a boolean, then specify the error message and path to indicate which field is invalid. This approach maintains type safety while enforcing cross-field constraints.
What is the difference between .refine and .superRefine in conditional validation?
.refine is designed for simple boolean checks and accepts a single error message or object. .superRefine provides the Zod context object (ctx), allowing you to add multiple custom issues with specific paths, codes, and messages—essential for complex conditional logic that generates several potential errors during a single validation pass.
When should I use a discriminated union instead of conditional validation?
Choose z.discriminatedUnion when the value of a discriminator field determines an entirely different object shape or set of required fields. Use conditional validation with .refine when the object structure remains consistent but field values must satisfy relationships dependent on other fields, such as numerical comparisons or required field dependencies.
How do I maintain type inference when implementing conditional Zod validation?
All native Zod methods for conditional validation—.refine, .superRefine, .transform, and discriminated unions—automatically preserve TypeScript type inference. Ensure you do not cast values to any within your validation functions, and use .pipe after .transform to maintain strict type checking through the validation chain. This approach aligns with the type safety patterns found in microsoft/TypeScript's src/compiler/types.ts.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →