Tim Kapitein

Parsing Discriminated Unions with Zod

I've been using Zod for TypeScript validation for years, but recently I discovered something I hadn't noticed before: discriminated union validators. While looking up recursive types with z.lazy in the docs, I came across the z.discriminatedUnion helper. It's nice to have runtime validation for discriminated unions in TypeScript.

Discriminated Unions in TypeScript

A discriminated union in TypeScript is a way to define types that can have different shapes, where one specific field tells us which shape we're dealing with. Let's look at an example:

type PaymentStatus =
  | { status: "success"; transactionId: string; amount: number }
  | { status: "failed"; errorCode: string; message: string };
 
// Example usage
const successfulPayment: PaymentStatus = {
  status: "success",
  transactionId: "tx_123",
  amount: 99.99,
};
 
const failedPayment: PaymentStatus = {
  status: "failed",
  errorCode: "INSUFFICIENT_FUNDS",
  message: "Not enough balance to process payment",
};

Looking at this type, the status field acts as our discriminator - it tells TypeScript which properties to expect. When the status is "success", we need a transaction ID and amount. For "failed" status, we expect an error code and message.

You'll often see this pattern when dealing with API responses, where you might get different data structures based on how things went. Think about fetching data - you either get what you asked for, or you get error details. Each case needs its own set of properties.

TypeScript is smart enough to figure out which properties you can use just by checking that status field. Sometimes that's all you need, but if you're dealing with external data and need runtime validation, Zod can help with that.

Validating at Runtime

While TypeScript helps us catch type errors during development, those checks disappear when our code runs in production - when the application is actually being used.

Without a validation library, checking these types at runtime typically requires verbose type guards:

function isPaymentStatus(data: unknown): data is PaymentStatus {
  if (typeof data !== "object" || data === null) {
    return false;
  }
 
  const paymentData = data as Record<string, unknown>;
 
  if (paymentData.status === "success") {
    return (
      typeof paymentData.transactionId === "string" &&
      typeof paymentData.amount === "number"
    );
  }
 
  if (paymentData.status === "failed") {
    return (
      typeof paymentData.errorCode === "string" &&
      typeof paymentData.message === "string"
    );
  }
 
  return false;
}

This approach has several drawbacks:

  • It's verbose and requires careful attention to edge cases
  • It's easy to miss properties or type checks
  • You need to maintain it alongside your type definitions

Using Zod's Solution

The discriminatedUnion function in Zod handles this validation for us. It takes two arguments: the name of our discriminator field (in our case "status"), and an array of possible object shapes.

Each shape is defined using Zod's schema builders:

import { z } from "zod";
 
const PaymentStatusSchema = z.discriminatedUnion("status", [
  z.object({
    status: z.literal("success"),
    transactionId: z.string(),
    amount: z.number().positive(),
  }),
  z.object({
    status: z.literal("failed"),
    errorCode: z.string(),
    message: z.string(),
  }),
]);
 
// Example parsing
const successCase = PaymentStatusSchema.parse({
  status: "success",
  transactionId: "tx_123",
  amount: 99.99,
}); // ✅ Valid input
// Result: { status: "success", transactionId: "tx_123", amount: 99.99 }
 
const failureCase = PaymentStatusSchema.parse({
  status: "failed",
  errorCode: "INVALID_CARD",
  message: "Card was declined",
}); // ✅ Valid input
// Result: { status: "failed", errorCode: "INVALID_CARD", message: "Card was declined" }
 
try {
  PaymentStatusSchema.parse({
    status: "success",
    errorCode: "OOPS", // ❌ Invalid: wrong properties for success status
  });
} catch (error) {
  console.error("Validation failed:", error.errors);
  // Shows validation errors
}

When we call parse on our schema, Zod first checks the "status" field to determine which shape to validate against, then ensures all required fields are present and have the correct types. If anything is wrong, it throws an error with detailed information about what failed.

Getting TypeScript Types from Our Schema

Zod makes it straightforward to get TypeScript types from our schema using the infer helper:

type Payment = z.infer<typeof PaymentStatusSchema>;
 
// Now you get TypeScript autocomplete
const payment: Payment = {
  status: "success",
  transactionId: "tx_456",
  amount: 299.99,
}; // TypeScript knows which fields are allowed
 
// TypeScript will error if you try to mix properties:
const invalidPayment: Payment = {
  status: "success",
  errorCode: "OOPS", // ❌ TypeScript Error: Property "errorCode" not allowed
};

With this approach, you get development-time type checking through TypeScript and runtime validation when you need it. The schema definition serves both purposes without extra overhead!