Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 039 / 100

Discriminated unions: a practical guide for developers

The most useful TypeScript pattern.

Expona Admin9 min read

When you're dealing with multiple possible states in TypeScript, it's tempting to use optional fields scattered across a type. But there's a better way. Discriminated unions: a practical guide for developers will show you how a single shared field can eliminate entire categories of bugs and make your code self-documenting.

We've all seen code like this: a payment object where sometimes you have a card, sometimes a bank account, sometimes neither. Without proper structure, you end up with defensive checks everywhere. Discriminated unions solve this by forcing you to handle every variant explicitly.

A shared literal field identifies each variant

The core idea is simple: each variant of your union carries a literal field that identifies what it is. This field is your discriminant. Let's start with a real example from payment processing.

typescript
type Payment =
  | { kind: 'card'; last4: string }
  | { kind: 'bank'; iban: string }
  | { kind: 'cash' };

Notice that every variant has a kind field, and each one has a different string literal value. TypeScript uses this to narrow the type automatically. When you check that payment.kind === 'card', TypeScript knows inside that block that payment must be the card variant, so payment.last4 is available and payment.iban does not exist.

The discriminant doesn't have to be called kind. You'll see type, status, state, or domain-specific names. The name matters less than consistency:

typescript
// API response example
type ApiResponse =
  | { status: 'success'; data: unknown }
  | { status: 'error'; code: string; message: string }
  | { status: 'pending' };

// UI state example
type FormState =
  | { state: 'idle' }
  | { state: 'loading' }
  | { state: 'error'; error: string }
  | { state: 'success'; result: string };

Each variant is a complete type. No optional fields, no null checks scattered through your code. The structure itself enforces what data is valid in each case.

Switch on it for exhaustive handling

Where discriminated unions really shine is in exhaustive pattern matching. TypeScript will force you to handle every possible variant, and warn you if you miss one.

typescript
function processPayment(payment: Payment): string {
  switch (payment.kind) {
    case 'card':
      return `Charging card ending in ${payment.last4}`;
    case 'bank':
      return `Transferring to account ${payment.iban}`;
    case 'cash':
      return 'Cash payment noted';
  }
}

If you remove one of those cases, TypeScript will error. The compiler knows you haven't covered all variants. This is exhaustiveness checking, and it's one of the most powerful features in TypeScript for preventing bugs at compile time.

Now imagine a scenario where your requirements change and you add a new payment method:

typescript
type Payment =
  | { kind: 'card'; last4: string }
  | { kind: 'bank'; iban: string }
  | { kind: 'cash' }
  | { kind: 'crypto'; walletAddress: string };

Every switch statement that processes payments immediately starts erroring until you add the crypto case. You can't accidentally forget to handle the new variant. You can't leave it in production missing a case. This scales beautifully as your codebase grows.

For complex logic, you can nest discriminated unions or combine them with if statements:

typescript
type Order =
  | { status: 'pending'; estimatedDate: Date }
  | { status: 'shipped'; trackingNumber: string; carrier: 'ups' | 'fedex' | 'dhl' }
  | { status: 'delivered'; deliveredAt: Date }
  | { status: 'cancelled'; reason: string };

function handleOrder(order: Order): void {
  if (order.status === 'pending') {
    console.log(`Ready to ship on ${order.estimatedDate}`);
  } else if (order.status === 'shipped') {
    const { trackingNumber, carrier } = order;
    console.log(`Track at ${carrier.com with ${trackingNumber}`);
  } else if (order.status === 'delivered') {
    console.log(`Arrived on ${order.deliveredAt}`);
  } else {
    const { reason } = order;
    console.log(`Cancelled: ${reason}`);
  }
}

Impossible states become impossible

One of the biggest benefits of discriminated unions is that they prevent invalid state combinations. In traditional object-oriented code, you might write:

typescript
// Before: using optional fields (bad)
type UserSession = {
  isLoggedIn: boolean;
  user?: { id: string; name: string };
  error?: string;
  loading?: boolean;
};

With this approach, you can end up with nonsensical states: isLoggedIn: true but no user, or both a user and an error message. You need defensive code everywhere to check combinations:

typescript
if (session.isLoggedIn && session.user && !session.error) {
  // This gets tedious fast
}

With discriminated unions, invalid states are simply not representable:

typescript
// After: using discriminated unions (good)
type UserSession =
  | { state: 'idle' }
  | { state: 'loading' }
  | { state: 'loggedIn'; user: { id: string; name: string } }
  | { state: 'error'; error: string };

Now you can't have a loading state and an error simultaneously. You can't have a logged-in session without a user. The type system guarantees correctness. This reduces bugs dramatically, especially in complex features like authentication, checkout flows, or real-time data synchronization.

Perfect for API responses and UI state

Discriminated unions shine brightest in two domains: API responses and UI state. Both are naturally multi-state scenarios that benefit from clear, exhaustive handling.

API responses are often either success or error, but may also have intermediate states like pending. Here's a realistic example from a trading system:

typescript
type TradeResponse =
  | { status: 'pending'; tradeId: string }
  | { status: 'executed'; tradeId: string; executedPrice: number; quantity: number }
  | { status: 'failed'; reason: 'insufficient_funds' | 'market_closed' | 'invalid_symbol' }
  | { status: 'cancelled'; reason: string };

async function executeTrade(order: TradeOrder): Promise {
  // implementation
}

async function handleTradeFlow() {
  const response = await executeTrade(myOrder);
  
  switch (response.status) {
    case 'pending':
      showNotification(`Trade pending. ID: ${response.tradeId}`);
      break;
    case 'executed':
      updateBalance(response.executedPrice * response.quantity);
      showSuccess(`Executed at ${response.executedPrice}`);
      break;
    case 'failed':
      if (response.reason === 'insufficient_funds') {
        showAlert('Add funds and try again');
      } else if (response.reason === 'market_closed') {
        showInfo('Markets are closed. Order queued.');
      }
      break;
    case 'cancelled':
      showWarning(`Trade cancelled: ${response.reason}`);
      break;
  }
}

UI state management benefits equally. Instead of managing separate booleans for loading, error, and success, a discriminated union ensures your UI is always in a valid state:

typescript
type CheckoutState =
  | { step: 'cart'; items: CartItem[] }
  | { step: 'shipping'; items: CartItem[]; address: Address }
  | { step: 'payment'; items: CartItem[]; address: Address; paymentMethod: Payment }
  | { step: 'processing' }
  | { step: 'success'; orderId: string }
  | { step: 'error'; message: string };

function renderCheckout(state: CheckoutState) {
  switch (state.step) {
    case 'cart':
      return ;
    case 'shipping':
      return ;
    case 'payment':
      return ;
    case 'processing':
      return ;
    case 'success':
      return ;
    case 'error':
      return ;
  }
}

Every step carries exactly the data it needs. You can't render the payment screen without a shipping address already present. You can't show success without an order ID. The component tree reflects reality.

Common mistakes

Using numbers or booleans as discriminants: While technically possible, string literals are more maintainable and debuggable:

typescript
// Avoid
type Result = { kind: 0; value: string } | { kind: 1; error: string };

// Prefer
type Result = { kind: 'success'; value: string } | { kind: 'error'; error: string };

Forgetting to include the discriminant in every variant: If one variant is missing the field, TypeScript can't narrow properly:

typescript
// Bad: cash variant missing 'kind'
type Payment =
  | { kind: 'card'; last4: string }
  | { kind: 'bank'; iban: string }
  | { }; // Missing discriminant

// Good: all variants include 'kind'
type Payment =
  | { kind: 'card'; last4: string }
  | { kind: 'bank'; iban: string }
  | { kind: 'cash' };

Nesting discriminants inconsistently: Keep the discriminant at the top level of your union for clarity and to avoid nested type guards:

typescript
// Harder to work with
type Response = {
  result?: { type: 'success'; data: unknown } | { type: 'error'; error: string };
};

// Cleaner
type Response =
  | { type: 'success'; data: unknown }
  | { type: 'error'; error: string };

Key takeaways

  • Use a literal discriminant field (commonly kind, type, or status) to identify each variant in a union.
  • TypeScript automatically narrows types when you check the discriminant, eliminating the need for defensive casts.
  • Switch statements on discriminated unions enable exhaustiveness checking, catching missing cases at compile time.
  • Discriminated unions make impossible states impossible to represent, reducing entire categories of bugs.
  • API responses and UI state are the two most impactful places to adopt this pattern in real applications.
  • Keep discriminants at the top level, use string literals, and ensure every variant includes the field.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles