Basic Usage
Create a schema that accepts any one of the provided options.
import { z } from 'zod';
const StringOrNumber = z.union([z.string(), z.number()]);
StringOrNumber.parse('hello'); // ✓ Valid
StringOrNumber.parse(42); // ✓ Valid
StringOrNumber.parse(true); // ✗ Invalid
type StringOrNumber = z.infer<typeof StringOrNumber>;
// string | number
Signature
function union<T extends readonly [SomeType, ...SomeType[]]>(
options: T,
params?: string | ZodUnionParams
): ZodUnion<T>
Array of schemas. Must contain at least two options. Input must match at least one.
Optional error message (string) or configuration object.
Properties
Access the union’s option schemas.const schema = z.union([z.string(), z.number()]);
schema.options; // [ZodString, ZodNumber]
Convenience Method
Use the .or() method as shorthand for unions:
const StringOrNumber = z.string().or(z.number());
// Equivalent to: z.union([z.string(), z.number()])
type StringOrNumber = z.infer<typeof StringOrNumber>;
// string | number
Multiple Options
Unions can have more than two options:
const Status = z.union([
z.literal('pending'),
z.literal('approved'),
z.literal('rejected'),
z.literal('cancelled'),
]);
type Status = z.infer<typeof Status>;
// "pending" | "approved" | "rejected" | "cancelled"
// Better approach: use z.enum()
const BetterStatus = z.enum(['pending', 'approved', 'rejected', 'cancelled']);
For literal unions, prefer z.enum() or z.literal([...]) for better error messages.
Complex Types
Object Unions
const Result = z.union([
z.object({
success: z.literal(true),
data: z.string(),
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]);
type Result = z.infer<typeof Result>;
// | { success: true; data: string }
// | { success: false; error: string }
const success = Result.parse({ success: true, data: 'Hello' });
const failure = Result.parse({ success: false, error: 'Failed' });
For object unions with a discriminator field, use z.discriminatedUnion() for better performance and error messages.
Nested Unions
const Nested = z.union([
z.string(),
z.union([
z.number(),
z.boolean(),
]),
]);
type Nested = z.infer<typeof Nested>;
// string | number | boolean
Array Unions
const MixedArray = z.union([
z.array(z.string()),
z.array(z.number()),
]);
type MixedArray = z.infer<typeof MixedArray>;
// string[] | number[]
MixedArray.parse(['a', 'b', 'c']); // ✓ Valid
MixedArray.parse([1, 2, 3]); // ✓ Valid
MixedArray.parse(['a', 1]); // ✗ Invalid - mixed types
Nullable and Optional
Nullable Types
const NullableString = z.union([z.string(), z.null()]);
// Equivalent to: z.string().nullable()
type NullableString = z.infer<typeof NullableString>;
// string | null
Optional Types
const OptionalString = z.union([z.string(), z.undefined()]);
// Equivalent to: z.string().optional()
type OptionalString = z.infer<typeof OptionalString>;
// string | undefined
Nullish Types
const NullishString = z.union([z.string(), z.null(), z.undefined()]);
// Equivalent to: z.string().nullish()
type NullishString = z.infer<typeof NullishString>;
// string | null | undefined
Type Inference
const schema = z.union([
z.string(),
z.number(),
z.object({ type: z.literal('custom'), value: z.unknown() }),
]);
type Output = z.infer<typeof schema>;
// string | number | { type: "custom"; value: unknown }
type Input = z.input<typeof schema>;
// Same as output for unions without transformations
Parsing Behavior
Zod tries each option in order until one succeeds:
const schema = z.union([
z.string().email(),
z.string().url(),
z.string(),
]);
schema.parse('[email protected]'); // Matches first option (email)
schema.parse('https://example.com'); // Matches second option (url)
schema.parse('hello'); // Matches third option (string)
Order matters! Place more specific schemas before more general ones.
// Bad: General schema first
const bad = z.union([
z.string(), // Matches everything
z.string().email(), // Never reached
]);
// Good: Specific schemas first
const good = z.union([
z.string().email(),
z.string(),
]);
Error Messages
When all options fail, Zod reports all validation errors:
const schema = z.union([
z.string().min(10),
z.number().positive(),
]);
try {
schema.parse('short');
} catch (error) {
console.log(error.errors);
// Reports:
// - String too short (from first option)
// - Invalid type: expected number (from second option)
}
Customize the error message:
const schema = z.union(
[z.string(), z.number()],
'Value must be a string or number'
);
Common Patterns
API Response Types
const ApiResponse = z.union([
z.object({
status: z.literal('success'),
data: z.unknown(),
}),
z.object({
status: z.literal('error'),
message: z.string(),
code: z.number(),
}),
]);
type ApiResponse = z.infer<typeof ApiResponse>;
Configuration Options
const Config = z.union([
z.object({
type: z.literal('local'),
path: z.string(),
}),
z.object({
type: z.literal('remote'),
url: z.string().url(),
apiKey: z.string(),
}),
]);
type Config = z.infer<typeof Config>;
ID Types
const ID = z.union([
z.string().uuid(),
z.number().int().positive(),
]);
type ID = z.infer<typeof ID>;
// string (uuid) | number
XOR (Exclusive Union)
For exclusive unions where exactly one option must match:
import { z } from 'zod';
const ExactlyOne = z.xor([
z.object({ a: z.string() }),
z.object({ b: z.number() }),
]);
ExactlyOne.parse({ a: 'hello' }); // ✓ Valid - matches first
ExactlyOne.parse({ b: 42 }); // ✓ Valid - matches second
ExactlyOne.parse({ a: 'hello', b: 42 }); // ✗ Invalid - matches both
ExactlyOne.parse({}); // ✗ Invalid - matches neither
Transform union values:
const Normalized = z.union([
z.string(),
z.number().transform(String),
]).transform((val) => val.toUpperCase());
Normalized.parse('hello'); // "HELLO"
Normalized.parse(42); // "42" (number converted to string, then uppercased)
type Normalized = z.infer<typeof Normalized>;
// string