Loading…
Loading…
Explore advanced Zod validation patterns including discriminated unions, transforms, refinements, and composable schemas for production applications.
Tags
This is part of the AI Automation Engineer Roadmap series.
Use Zod's transform, refine, and discriminatedUnion to build composable validation schemas that parse, coerce, and validate complex data with full TypeScript inference.
Form inputs arrive as strings. API payloads have optional fields that need defaults. Union types need different validation rules based on a discriminator field. Writing this validation logic by hand is repetitive and error-prone, and keeping your TypeScript types in sync with runtime checks is a maintenance headache.
Zod works well in production because it solves two problems at once:
That removes the usual drift between your validation rules and your application types.
import { z } from "zod";
const PriceSchema = z.object({
amount: z.string().transform((val) => parseFloat(val)),
currency: z
.string()
.toUpperCase()
.transform((val) => val.trim()),
date: z.string().transform((val) => new Date(val)),
});
type Price = z.infer<typeof PriceSchema>;
// { amount: number; currency: string; date: Date }Transforms run after validation, so the input is a string but the output type is automatically inferred as number or Date.
This is especially useful when your UI or API accepts loose input, but your application logic expects normalized values.
Common uses:
Dateconst PasswordSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});Use .refine() for cross-field validation that Zod's built-in validators cannot express. The path option attaches the error to a specific field.
For more complex business logic, use superRefine() so you can add multiple issues in one pass.
const BookingSchema = z
.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
guests: z.number().int().positive(),
})
.superRefine((data, ctx) => {
if (data.endDate <= data.startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "End date must be after start date",
});
}
if (data.guests > 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["guests"],
message: "Maximum booking size is 8 guests",
});
}
});That pattern is a better fit when the rules reflect actual domain constraints rather than simple field-level validation.
const NotificationSchema = z.discriminatedUnion("channel", [
z.object({
channel: z.literal("email"),
emailAddress: z.string().email(),
}),
z.object({
channel: z.literal("sms"),
phoneNumber: z.string().regex(/^\+\d{10,15}$/),
}),
z.object({
channel: z.literal("push"),
deviceToken: z.string().uuid(),
}),
]);Discriminated unions check the channel field first, then apply only the matching schema. This is more performant and produces clearer error messages than z.union().
This pattern is ideal for:
type fieldIf your data shape depends on one known key, discriminatedUnion is usually the cleanest option.
The real payoff with Zod comes from composition. Instead of writing one giant schema, define reusable pieces and combine them.
const BaseUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.coerce.date(),
});
const ProfileSchema = z.object({
displayName: z.string().min(2),
bio: z.string().max(160).optional(),
});
const UserWithProfileSchema = BaseUserSchema.extend({
profile: ProfileSchema,
});This keeps your schemas maintainable as the application grows.
Useful composition tools:
.extend() to add fields.merge() to combine object schemas.pick() and .omit() for variants.partial() for update payloadsOne common mistake is using the same schema for raw form input and normalized server-side data.
A cleaner pattern is:
const UserInputSchema = z.object({
name: z.string().min(1),
age: z.string(),
});
const UserDomainSchema = UserInputSchema.transform((data) => ({
...data,
age: Number(data.age),
}));That separation keeps validation readable and makes debugging easier.
Zod handles validation. React Hook Form handles field registration, UI state, and interaction flow.
The practical rule is:
If you put too much UI behavior into the schema, the validation layer becomes hard to reason about.
transform() for ValidationTransforms should normalize or convert values. If you are actually enforcing a rule, use refine() or superRefine() instead.
Large monolithic schemas become hard to test and reuse. Break them into small domain-oriented pieces.
Zod is most useful at boundaries:
If you define schemas but do not actually call parse or safeParse at the boundary, you lose most of the value.
Validation errors are part of the product. Make sure the structure works for forms, APIs, and logs.
If you are using Zod in a real application, the most reliable setup is:
superRefine() for true business rulesZod schemas are composable functions. Transforms create a pipeline where the output type differs from the input type, and TypeScript infers both automatically. Refinements add arbitrary validation without losing type information. Discriminated unions use a literal discriminant to narrow to the correct branch in O(1) time, avoiding the trial-and-error approach of regular unions. Together, these three patterns handle the vast majority of real-world validation scenarios.
Zod is strongest when you treat it as a boundary and normalization layer, not just a form helper. Use transforms for parsing, refinements for business rules, and discriminated unions for conditional shapes, then compose small schemas instead of fighting one giant one.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Five essential built-in generic utility types in TypeScript that will save you hundreds of lines of code.
Generate dynamic Open Graph images in Next.js using the ImageResponse API with custom fonts, gradients, and data-driven content for social sharing.
Create reusable GitHub Actions workflows with inputs, secrets, and outputs to eliminate YAML duplication across repositories and teams efficiently.