Zod 11 🛡️ Unions and Discriminated Unions — z.union() and z.discriminatedUnion()
A union schema accepts data that matches any of its branches. z.union([A, B]) tries each branch in sequence and succeeds if any matches. z.discriminatedUnion("type", [A, B]) reads a shared literal field first, then validates only the branch whose discriminator matches. The two produce the same accepted set in many cases, but they differ in validation cost and error quality: the discriminated form is faster because it dispatches directly, and its errors name the discriminator rather than listing every failed branch .
This chapter covers the union constructor, the discriminated union constructor, and the decision between them. It explains why the discriminated form should be preferred for object schemas, how to structure the branches so the discriminator is found, and the v4 typing changes that affect what TypeScript can infer from a discriminated union. The sections that follow walk through plain unions and their sequential validation, discriminated unions and their dispatch mechanism, and the composition patterns that merge multiple discriminated unions.
Key point: Use z.discriminatedUnion() for object schemas that share a literal field; it dispatches in O(1) and produces a single precise error, while z.union() tries every branch sequentially and reports every failure.
Why the distinction exists
The sequential validation problem. A plain union validates by trial. For z.union([A, B]), the parser runs A first; if it fails, it runs B; if both fail, the error contains the issues from both branches. For a union of two branches, the cost is at most two validations. For a union of ten branches, an invalid input runs ten validations before reporting failure. The error is also noisy: the consumer sees every branch’s failure rather than the one that matters.
The dispatch problem. When every branch shares a literal field that identifies it, the parser does not need to try branches. z.discriminatedUnion("type", [...]) reads the type value from the input, looks up the matching branch, and validates only that branch . The lookup is constant-time. The error, when the discriminator is missing or unknown, names the discriminator rather than listing every branch’s failure .
The v4 typing change. In Zod v3, z.discriminatedUnion was generic over the discriminator key, and TypeScript could infer the possible values of that key from the options. In v4, the discriminator parameter is typed as string, and the generic inference changed . This does not affect runtime behavior, but it affects what the compiler knows about the union’s discriminator field. The practical consequence is that narrowing on event.type inside a switch still works because the branches have literal types, but the discriminated union type itself no longer exposes the discriminator key as a type parameter.
a. z.union() and sequential validation
z.union([schema1, schema2, ...]) accepts any value that matches at least one of the listed schemas. The inferred type is the union of the branches’ inferred types. The validation is sequential: each branch is tried in order until one succeeds.
import { z } from "zod";
// Union of primitives
const StringOrNumber = z.union([z.string(), z.number()]);
StringOrNumber.parse("hello"); // valid
StringOrNumber.parse(123); // valid
StringOrNumber.parse(true); // ZodError: expected string | number
The error from a failed plain union contains issues from every branch. This is useful when the branches are genuinely unrelated and the consumer needs to know all the ways the input was wrong. It is noisy when the branches are variants of the same shape and only one was meant to match.
Unions of object schemas work, but they lose the dispatch optimization. A union of three object schemas that each have a type field will try all three in sequence for an input with type: "unknown", and the error will contain three separate “invalid literal” issues.
b. z.discriminatedUnion() and direct dispatch
A discriminated union requires every branch to be an object schema with a shared key whose value is a literal (or an enum member). The constructor takes the key name and the array of branches.
const EventSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("user_created"),
userId: z.string().uuid(),
email: z.email(),
}),
z.object({
type: z.literal("user_deleted"),
userId: z.string().uuid(),
reason: z.string().optional(),
}),
]);
type Event = z.infer<typeof EventSchema>;
// { type: "user_created"; userId: string; email: string }
// | { type: "user_deleted"; userId: string; reason?: string }
The parser reads type, finds the branch whose literal matches, and validates against that branch only. If type is "user_created", the user_deleted branch is never executed. If type is missing or unknown, the error names the discriminator, not the branches.
The discriminator value must be a literal type in every branch. z.literal("user_created") and z.enum(["user_created", "user_deleted"]) both work; z.string() does not, because the parser cannot build a lookup table from an open set. A branch whose discriminator is not a literal fails at construction or at first parse, depending on the wrapper .
The inferred type narrows correctly in a switch on the discriminator, which is the reason the discriminated union exists: the consumer can handle each variant with exhaustive checking .
function handleEvent(event: Event) {
switch (event.type) {
case "user_created":
return sendWelcomeEmail(event.email); // email is known here
case "user_deleted":
return cleanupUserData(event.userId); // reason is optional here
}
}
c. Merging discriminated unions and the v4 caveats
Multiple discriminated unions can be merged by spreading their .options arrays into a new constructor, as long as they share the same discriminator key.
const A = z.discriminatedUnion("status", [
z.object({ status: z.literal("ok"), data: z.string() }),
]);
const B = z.discriminatedUnion("status", [
z.object({ status: z.literal("error"), message: z.string() }),
]);
const AB = z.discriminatedUnion("status", [...A.options, ...B.options]);
The v4 caveat is the typing change documented in issue #5024: the discriminator is no longer a generic type parameter, so TypeScript cannot extract the possible values from the schema type itself . The runtime behavior is unchanged; the narrowing inside a switch still works because the branches carry the literal types. The loss is in meta-programming that tried to derive the discriminator union from the schema type rather than from z.infer.
A second caveat is the wrapped-discriminator bug reported in v4.5: branches whose discriminator is wrapped in .default() throw a “Duplicate discriminator value” error at first parse, even when the values are distinct . The workaround is to avoid wrapping the discriminator literal in a default, or to use a plain z.union() for that specific case. The issue was under investigation, and the fix targets the map construction site rather than the factory.
Complete Example Session
// ============================================
// PART 1: PLAIN UNION (SEQUENTIAL)
// ============================================
import { z } from "zod";
const PaymentSchema = z.union([
z.object({ method: z.literal("card"), last4: z.string().length(4) }),
z.object({ method: z.literal("paypal"), email: z.email() }),
]);
// Both branches are tried for any input
PaymentSchema.parse({ method: "card", last4: "4242" }); // valid
PaymentSchema.parse({ method: "unknown" }); // error lists both branches
// ============================================
// PART 2: DISCRIMINATED UNION (DISPATCH)
// ============================================
const BetterPaymentSchema = z.discriminatedUnion("method", [
z.object({ method: z.literal("card"), last4: z.string().length(4) }),
z.object({ method: z.literal("paypal"), email: z.email() }),
]);
// Only the matching branch is validated
BetterPaymentSchema.parse({ method: "card", last4: "4242" }); // valid
BetterPaymentSchema.parse({ method: "unknown" }); // error names the discriminator
// ============================================
// PART 3: EXHAUSTIVE NARROWING
// ============================================
type Payment = z.infer<typeof BetterPaymentSchema>;
function describe(payment: Payment): string {
switch (payment.method) {
case "card":
return `Card ending ${payment.last4}`;
case "paypal":
return `PayPal: ${payment.email}`;
}
}
// ============================================
// PART 4: MERGING UNIONS
// ============================================
const RefundSchema = z.discriminatedUnion("method", [
z.object({ method: z.literal("card_refund"), last4: z.string() }),
z.object({ method: z.literal("paypal_refund"), email: z.email() }),
]);
const AllPayments = z.discriminatedUnion("method", [
...BetterPaymentSchema.options,
...RefundSchema.options,
]);
The parts covered the sequential plain union, the dispatch-based discriminated union, the exhaustive narrowing, and the merge pattern.
Quick Reference
Union Constructors
| Constructor | Validation | Error | Use For |
|---|---|---|---|
z.union([A, B]) | Sequential trial | All branch issues | Unrelated primitives |
z.discriminatedUnion("key", [A, B]) | Direct dispatch | Single discriminator error | Tagged objects |
Discriminated Union Requirements
| Requirement | Detail |
|---|---|
| Branch type | Object schema |
| Discriminator field | Present in every branch |
| Discriminator value | Literal or enum member |
| Discriminator key | Same string in every branch |
v4 Changes
| Change | Effect |
|---|---|
| Discriminator not generic | TypeScript cannot extract key values from schema type |
| Wrapped discriminator bug | .default() on discriminator throws at parse |
Best Practices
✅ Do This:
// Use discriminatedUnion for tagged objects
z.discriminatedUnion("type", [A, B]) // ✅
// Use literal discriminators
type: z.literal("created") // ✅
// Merge via .options
z.discriminatedUnion("type", [...A.options, ...B.options]) // ✅
// Narrow with switch on the discriminator
switch (event.type) { ... } // ✅
❌ Don’t Do This:
// Use plain union for tagged objects
z.union([A, B]) // slower, noisier errors // ❌
// Use an open string as discriminator
type: z.string() // cannot dispatch // ❌
// Wrap the discriminator in .default() (v4.5 bug)
type: z.literal("a").default("a") // ❌
// Expect v4 to infer discriminator values from schema type
// (not generic in v4) // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Plain union is slow | Sequential validation | Use z.discriminatedUnion() |
| Noisy error lists all branches | Union tries every branch | Discriminated union reports one |
| Discriminator not found | Branch missing the key | Add the literal field to every branch |
| “Duplicate discriminator” at parse | Wrapped literal with .default() | Remove the wrapper |
| Cannot narrow on discriminator | Plain union of objects | Use discriminated union |
Real-World Examples
1. Plain primitive union
z.union([z.string(), z.number()]);
2. Discriminated union
z.discriminatedUnion("type", [CreatedSchema, DeletedSchema]);
3. Literal discriminator
type: z.literal("user_created");
4. Exhaustive switch
switch (event.type) { case "user_created": ... }
5. Merge via options
z.discriminatedUnion("status", [...A.options, ...B.options]);
6. Optional field in branch
reason: z.string().optional(); // in user_deleted branch
7. Enum discriminator
type: z.enum(["a", "b"]);
8. Plain union of objects (avoid)
z.union([CardSchema, PaypalSchema]); // use discriminated instead
9. v4 typing caveat
// Discriminator key not generic in v4
10. Wrapped discriminator bug
// .default() on literal throws at parse
Visual
Plain Union Validation
┌─────────────────────────────────────────────────────────┐
│ z.union([A, B]) │
│ │
│ input ──► try A ──► fail ──► try B ──► fail │
│ │ │
│ ▼ │
│ ZodError with │
│ issues from A and B │
│ │
│ Sequential, noisy, O(n) branches │
└─────────────────────────────────────────────────────────┘
Discriminated Union Dispatch
┌─────────────────────────────────────────────────────────┐
│ z.discriminatedUnion("type", [A, B]) │
│ │
│ input ──► read "type" ──► lookup ──► validate A only │
│ │ │
│ └── unknown ──► error names discriminator│
│ │
│ Direct, precise, O(1) dispatch │
└─────────────────────────────────────────────────────────┘
Exhaustive Narrowing
┌─────────────────────────────────────────────────────────┐
│ type Event = │
│ | { type: "created"; email: string } │
│ | { type: "deleted"; reason?: string } │
│ │
│ switch (event.type) │
│ case "created": │
│ event.email ◄── known to exist │
│ case "deleted": │
│ event.reason ◄── known to be optional │
│ │
│ The discriminated union makes the switch exhaustive │
└─────────────────────────────────────────────────────────┘
Merge Flow
┌─────────────────────────────────────────────────────────┐
│ const A = z.discriminatedUnion("status", [A1, A2]) │
│ const B = z.discriminatedUnion("status", [B1, B2]) │
│ │
│ const AB = z.discriminatedUnion("status", [ │
│ ...A.options, ◄── spread the branch arrays │
│ ...B.options │
│ ]) │
│ │
│ Result: one union with A1, A2, B1, B2 │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Plain union | z.union([A, B]) |
| Discriminated union | z.discriminatedUnion("key", [A, B]) |
| Discriminated requirement | Literal discriminator in every branch |
| Dispatch cost | O(1) with discriminated, O(n) with plain |
| Error quality | Single discriminator error vs all branch issues |
| Merge pattern | [...A.options, ...B.options] |
| v4 typing change | Discriminator not generic |
| v4.5 bug | .default() on discriminator throws |
Key takeaways:
- Use
z.discriminatedUnion()for tagged objects. It dispatches in constant time and reports one precise error instead of trying every branch and listing every failure . - The discriminator must be a literal.
z.literal("type")or an enum member works;z.string()does not, because the parser cannot build a lookup table from an open set. - The inferred type narrows exhaustively. A
switchon the discriminator gives the consumer the branch-specific fields without casts, which is the reason the discriminated form exists for object unions . - Merging uses
.options. Spread the branch arrays into a new constructor when combining unions that share a discriminator key. - v4 has two caveats. The discriminator is no longer generic, so TypeScript cannot extract the key values from the schema type ; and wrapping the discriminator literal in
.default()throws a duplicate-value error at first parse in v4.5 .
Remember: A union is a choice between shapes; a discriminated union is a lookup keyed by a field. The plain union is correct for unrelated primitives where the consumer needs to know every way the input was wrong. The discriminated union is correct for tagged objects where the consumer wants one branch and one error. When the branches share a literal field, the discriminated form is faster and more precise; when they do not, the plain union is the only option. Structure the data so the discriminator exists, and the union becomes a dispatch table rather than a trial.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!