Files
gruperly/.agents/skills/zod/references/object-discriminated-unions.md
2026-09-04 16:49:24 -03:00

3.9 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Use Discriminated Unions for Type Narrowing MEDIUM-HIGH Regular unions require manual type guards; discriminated unions enable TypeScript's automatic narrowing and Zod's optimized parsing object, discriminatedUnion, narrowing, typescript

Use Discriminated Unions for Type Narrowing

When a field's type depends on another field's value (e.g., type: 'success' means data exists, type: 'error' means error exists), use z.discriminatedUnion(). This enables TypeScript's automatic type narrowing and Zod's optimized O(1) parsing instead of trying each variant.

Incorrect (regular union - no automatic narrowing):

import { z } from 'zod'

const successSchema = z.object({
  type: z.literal('success'),
  data: z.object({ id: z.string() }),
})

const errorSchema = z.object({
  type: z.literal('error'),
  message: z.string(),
})

// Regular union - Zod tries each option in order
const responseSchema = z.union([successSchema, errorSchema])

type Response = z.infer<typeof responseSchema>

function handleResponse(response: Response) {
  // TypeScript doesn't narrow automatically
  if (response.type === 'success') {
    response.data  // Error: Property 'data' does not exist on type 'Response'
    // Must cast or use type guards
  }
}

Correct (discriminated union):

import { z } from 'zod'

const successSchema = z.object({
  type: z.literal('success'),
  data: z.object({ id: z.string() }),
})

const errorSchema = z.object({
  type: z.literal('error'),
  message: z.string(),
})

// Discriminated union - Zod uses 'type' field for O(1) dispatch
const responseSchema = z.discriminatedUnion('type', [
  successSchema,
  errorSchema,
])

type Response = z.infer<typeof responseSchema>

function handleResponse(response: Response) {
  // TypeScript narrows automatically!
  if (response.type === 'success') {
    response.data.id  // Works - TypeScript knows data exists
  } else {
    response.message  // Works - TypeScript knows message exists
  }
}

Common use cases:

// API responses
const apiResponse = z.discriminatedUnion('status', [
  z.object({ status: z.literal('success'), data: z.unknown() }),
  z.object({ status: z.literal('error'), error: z.string(), code: z.number() }),
  z.object({ status: z.literal('loading') }),
])

// Event types
const event = z.discriminatedUnion('type', [
  z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
  z.object({ type: z.literal('keypress'), key: z.string() }),
  z.object({ type: z.literal('scroll'), delta: z.number() }),
])

// Database records with polymorphic types
const notification = z.discriminatedUnion('channel', [
  z.object({ channel: z.literal('email'), address: z.string().email() }),
  z.object({ channel: z.literal('sms'), phoneNumber: z.string() }),
  z.object({ channel: z.literal('push'), deviceToken: z.string() }),
])

Type-safe handling:

const paymentSchema = z.discriminatedUnion('method', [
  z.object({
    method: z.literal('card'),
    cardNumber: z.string(),
    expiryDate: z.string(),
  }),
  z.object({
    method: z.literal('bank'),
    accountNumber: z.string(),
    routingNumber: z.string(),
  }),
  z.object({
    method: z.literal('crypto'),
    walletAddress: z.string(),
  }),
])

type Payment = z.infer<typeof paymentSchema>

function processPayment(payment: Payment) {
  switch (payment.method) {
    case 'card':
      return chargeCard(payment.cardNumber, payment.expiryDate)
    case 'bank':
      return initiateBankTransfer(payment.accountNumber, payment.routingNumber)
    case 'crypto':
      return sendCrypto(payment.walletAddress)
    // TypeScript exhaustiveness check - no default needed
  }
}

When NOT to use this pattern:

  • When variants don't share a common discriminator field
  • When the discriminator isn't a literal type (use regular union)

Reference: Zod API - Discriminated Unions