Skip to main content

TS Ep 11: Discriminated Unions — Tagged Unions & Domain Modeling

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 11: This Article
If you only master one design pattern in TypeScript, make it Discriminated Unions (also known as Tagged Unions or Algebraic Sum Types). Discriminated Unions eliminate impossible states, provide flawless autocomplete, and turn complex state management into compile-time verified logic.

1. The Problem: The “Optional Property Blob” Anti-Pattern
#

When junior developers model dynamic states (like an HTTP request or a payment transaction), they frequently create a single monolithic interface filled with optional properties:

// ❌ ANTI-PATTERN: The Monolithic Optional Blob
interface BadAsyncState {
  status: "idle" | "loading" | "success" | "error";
  data?: string[];
  error?: Error;
  loadingTimeMs?: number;
}

Why this design fails catastrophically:
#

  1. Allows Impossible States: Nothing prevents a developer from constructing an object with status: "loading" AND data: ["items"] AND error: new Error(). What does this object actually represent?
  2. Requires Non-Null Assertions: Even inside if (state.status === "success"), TypeScript views state.data as string[] | undefined, forcing you to use unsafe non-null assertions (state.data!) or redundant optional chaining (state.data?.length).
function processBadState(state: BadAsyncState) {
  if (state.status === "success") {
    // ❌ Error: 'state.data' is possibly 'undefined'.
    // console.log(state.data.length); 

    // Developer is forced to write redundant, unsafe checks:
    if (state.data) {
      console.log(state.data.length);
    }
  }
}

2. The Solution: Discriminated Unions (Tagged Unions)
#

In Type Theory, a Discriminated Union is an implementation of a Sum Type (an Algebraic Data Type).

Instead of one interface with optional properties, we create a Union of distinct, discrete interfaces, where each member interface possesses a common property with a unique literal type.

The 3 Requirements for a Discriminated Union
#

  1. The Discriminant (Tag): A shared property name present in every member interface (e.g., kind, type, status).
  2. Literal Types: The discriminant property in each member interface must have a distinct literal type ("loading", "success", "error").
  3. The Union: A type alias uniting the discrete interfaces (type State = Idle | Loading | Success | Error).
// 🟢 GOOD PATTERN: Discriminated Union
interface IdleState {
  status: "idle";
}

interface LoadingState {
  status: "loading";
  startTime: number;
}

interface SuccessState {
  status: "success";
  data: string[];
  fetchedAt: Date;
}

interface ErrorState {
  status: "error";
  error: Error;
}

// The Discriminated Union Type
type AsyncState = IdleState | LoadingState | SuccessState | ErrorState;

3. How Control Flow Analysis Narrows Discriminants
#

When you inspect the discriminant property using an if or switch statement, TypeScript’s Control Flow Analyzer automatically narrows the entire union variable to the specific member matching that literal tag!

function renderUI(state: AsyncState) {
  switch (state.status) {
    case "idle":
      // Inferred: IdleState
      console.log("Press start to load data.");
      break;

    case "loading":
      // Inferred: LoadingState
      // 'startTime' is guaranteed to exist! 'data' and 'error' CANNOT exist!
      console.log(`Loading started at: ${state.startTime}`);
      break;

    case "success":
      // Inferred: SuccessState
      // 'data' is guaranteed to be string[] (NOT undefined)!
      console.log(`Success! Loaded ${state.data.length} items.`);
      break;

    case "error":
      // Inferred: ErrorState
      // 'error' is guaranteed to be Error!
      console.error(`Failed with message: ${state.error.message}`);
      break;
  }
}

Why this changes everything:
#

  • Impossible States are Unrepresentable: You literally cannot create an IdleState that accidentally contains error: Error.
  • Zero Optional Property Noise: state.data in SuccessState is typed as string[], eliminating the need for ? or !.
  • Intellisense Autocomplete: Inside case "success", your IDE will only suggest status, data, and fetchedAt. It will hide error and startTime.

4. Real-World Example: Redux Action Reducers
#

The entire state management ecosystem of Redux, NgRx, and XState is built directly on Discriminated Unions:

interface AddTodoAction {
  type: "ADD_TODO";
  payload: { id: string; text: string };
}

interface ToggleTodoAction {
  type: "TOGGLE_TODO";
  payload: { id: string };
}

interface ClearCompletedAction {
  type: "CLEAR_COMPLETED";
}

type TodoAction = AddTodoAction | ToggleTodoAction | ClearCompletedAction;

interface TodoState {
  todos: Array<{ id: string; text: string; completed: boolean }>;
}

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case "ADD_TODO":
      // Action is narrowed to AddTodoAction
      return {
        ...state,
        todos: [...state.todos, { id: action.payload.id, text: action.payload.text, completed: false }],
      };

    case "TOGGLE_TODO":
      // Action is narrowed to ToggleTodoAction
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };

    case "CLEAR_COMPLETED":
      // Action is narrowed to ClearCompletedAction
      return {
        ...state,
        todos: state.todos.filter((todo) => !todo.completed),
      };
  }
}

5. Real-World Example: Payment Gateway Transactions
#

interface CreditCardPayment {
  method: "credit_card";
  cardNumber: string;
  cvv: string;
}

interface PayPalPayment {
  method: "paypal";
  payerEmail: string;
}

interface CryptoPayment {
  method: "crypto";
  walletAddress: string;
  network: "ethereum" | "bitcoin";
}

type PaymentMethod = CreditCardPayment | PayPalPayment | CryptoPayment;

function processPayment(payment: PaymentMethod) {
  if (payment.method === "credit_card") {
    console.log(`Processing card ending in ${payment.cardNumber.slice(-4)}`);
  } else if (payment.method === "paypal") {
    console.log(`Redirecting to PayPal for email: ${payment.payerEmail}`);
  } else {
    console.log(`Waiting for ${payment.network} confirmation to ${payment.walletAddress}`);
  }
}

Summary & Next Steps
#

In this episode:

  • We demonstrated why the “Monolithic Optional Blob” anti-pattern allows impossible states and requires non-null noise.
  • We constructed Discriminated Unions using the 3 core requirements (Discriminant, Literal Types, Union).
  • We analyzed how if and switch statements refine discriminants effortlessly.
  • We explored production implementations in Redux Reducers and Payment Gateway workflows.

In Episode 12: Function Typing and the Void Type, we will master typing functions, arrow functions, parameter defaults, optional parameters, and the exact semantics of void!

typescript - This article is part of a series.
Part 11: This Article