| | |

Zod 11 🛡️ Unions and Discriminated Unions — z.union() and z.discriminatedUnion()

⏱️ Estimated read: 11 min | |

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

ConstructorValidationErrorUse For
z.union([A, B])Sequential trialAll branch issuesUnrelated primitives
z.discriminatedUnion("key", [A, B])Direct dispatchSingle discriminator errorTagged objects

Discriminated Union Requirements

RequirementDetail
Branch typeObject schema
Discriminator fieldPresent in every branch
Discriminator valueLiteral or enum member
Discriminator keySame string in every branch

v4 Changes

ChangeEffect
Discriminator not genericTypeScript 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

PitfallWhy It HappensFix
Plain union is slowSequential validationUse z.discriminatedUnion()
Noisy error lists all branchesUnion tries every branchDiscriminated union reports one
Discriminator not foundBranch missing the keyAdd the literal field to every branch
“Duplicate discriminator” at parseWrapped literal with .default()Remove the wrapper
Cannot narrow on discriminatorPlain union of objectsUse 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

ItemValue
Plain unionz.union([A, B])
Discriminated unionz.discriminatedUnion("key", [A, B])
Discriminated requirementLiteral discriminator in every branch
Dispatch costO(1) with discriminated, O(n) with plain
Error qualitySingle discriminator error vs all branch issues
Merge pattern[...A.options, ...B.options]
v4 typing changeDiscriminator 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 switch on 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.

Was this helpful?


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!