Zod Union and Discriminated Union
Model variant types with z.union, z.discriminatedUnion, z.intersection, and type narrowing
When to Use
- Modeling a field that can be one of several different shapes (tagged union / ADT pattern)
- Validating polymorphic API payloads where a
typefield determines the shape - Combining two schemas where all fields from both must be present (intersection)
- Narrowing parsed output to a specific variant using the discriminant field
Instructions
- Use
z.union()for a simple union of schemas — Zod tries each option in order:
import { z } from 'zod';
const StringOrNumberSchema = z.union([z.string(), z.number()]);
// Accepts: 'hello', 42
// Rejects: true, null, {}
const IdSchema = z.union([z.string().uuid(), z.number().int().positive()]);
- Use
z.discriminatedUnion()when variants share a literal discriminant field — it is significantly faster thanz.union()because it selects the branch before trying to parse:
const NotificationSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('email'),
to: z.string().email(),
subject: z.string(),
body: z.string(),
}),
z.object({
type: z.literal('sms'),
phone: z.string(),
message: z.string().max(160),
}),
z.object({
type: z.literal('push'),
deviceToken: z.string(),
title: z.string(),
body: z.string(),
}),
]);
type Notification = z.infer<typeof NotificationSchema>;
// { type: 'email'; to: string; subject: string; body: string }
// | { type: 'sms'; phone: string; message: string }
// | { type: 'push'; deviceToken: string; title: string; body: string }
- Narrow the discriminated union in application code using the discriminant field:
function handleNotification(notification: Notification) {
switch (notification.type) {
case 'email':
// TypeScript knows: notification.to, notification.subject, notification.body
sendEmail(notification.to, notification.subject, notification.body);
break;
case 'sms':
// TypeScript knows: notification.phone, notification.message
sendSms(notification.phone, notification.message);
break;
case 'push':
sendPush(notification.deviceToken, notification.title, notification.body);
break;
}
}
- Use
z.intersection()when all fields from both schemas must be present simultaneously:
const TimestampedSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
});
const NamedSchema = z.object({
name: z.string(),
description: z.string().optional(),
});
const TimestampedNamedSchema = z.intersection(TimestampedSchema, NamedSchema);
// Equivalent to: { createdAt: Date; updatedAt: Date; name: string; description?: string }
// Note: .merge() is usually preferred over z.intersection() for object schemas
const PreferredSchema = NamedSchema.merge(TimestampedSchema);
- Use
z.discriminatedUnion()with nested discriminants by chaining:
const EventSchema = z.discriminatedUnion('category', [
z.object({
category: z.literal('user'),
action: z.discriminatedUnion('type', [
z.object({ type: z.literal('created'), userId: z.string() }),
z.object({ type: z.literal('deleted'), userId: z.string(), reason: z.string() }),
]),
}),
z.object({
category: z.literal('system'),
code: z.number(),
message: z.string(),
}),
]);
- Use
.optionsto access individual variants of a union for reuse:
const [EmailNotifSchema, SmsNotifSchema, PushNotifSchema] = NotificationSchema.options;
Details
z.union() vs z.discriminatedUnion() — performance:
z.union() tries each schema in order and returns the first success. For 10 variants, this means up to 10 full parse attempts. z.discriminatedUnion() uses the discriminant field as a lookup key — it selects exactly one branch regardless of how many variants exist. Always prefer z.discriminatedUnion() when your union has a shared literal field.
Common discriminant field names:
type, kind, tag, variant, event, action — pick one and be consistent across your codebase.
Branded types with unions:
const SuccessSchema = z.object({ success: z.literal(true), data: z.unknown() });
const ErrorSchema = z.object({ success: z.literal(false), error: z.string() });
const ResultSchema = z.discriminatedUnion('success', [SuccessSchema, ErrorSchema]);
type Result<T> = { success: true; data: T } | { success: false; error: string };
Intersection caveats:
z.intersection() does not merge — it validates both schemas independently. Overlapping keys must satisfy both constraints:
const A = z.object({ age: z.number().min(0) });
const B = z.object({ age: z.number().max(120) });
const AB = z.intersection(A, B);
// age must satisfy both: >= 0 AND <= 120
Source
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.