TypeScript Type Guards for Discriminated Unions (Best Practices for Scalable Code)

Published: (March 14, 2026 at 01:27 PM EDT)
5 min read
Source: Dev.to

Source: Dev.to

In this guide we’ll cover

  • What discriminated unions are
  • Why type guards matter
  • Best practices for using them in real applications
  • Common mistakes developers make

The Problem: Handling Multiple Data Shapes

In many applications, a variable can represent different types of objects (e.g., API response states).

type ApiResponse =
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

This is a union type.

But how does TypeScript know which properties exist?

function handleResponse(res: ApiResponse) {
  console.log(res.data);
}

TypeScript error

Property 'data' does not exist on type 'ApiResponse'.

Because not every union member has data.

Solution: Discriminated Unions

A discriminated union uses a common property (the discriminator) to identify the type. In our example the discriminator is status.

Now TypeScript can narrow types safely:

function handleResponse(res: ApiResponse) {
  if (res.status === "success") {
    console.log(res.data);
  }
}

TypeScript automatically knows that inside the if block res is:

{ status: "success"; data: string[] }

This is called type narrowing.

What Are Type Guards?

A type guard is logic that helps TypeScript determine the exact type.

if (res.status === "error") { /* … */ }

The condition acts as a type guard.

We can also create custom type guards.

Creating Custom Type Guards

Custom guards improve readability and reusability.

function isSuccess(
  res: ApiResponse
): res is { status: "success"; data: string[] } {
  return res.status === "success";
}

Usage

if (isSuccess(res)) {
  console.log(res.data); // `res` is narrowed automatically
}

This is very useful in large applications.

Real‑World Example: Payment System

Consider a payment system where responses differ.

type PaymentResult =
  | { type: "success"; transactionId: string }
  | { type: "failed"; error: string }
  | { type: "pending"; estimatedTime: number };

Using a discriminated union:

function handlePayment(result: PaymentResult) {
  switch (result.type) {
    case "success":
      console.log(result.transactionId);
      break;

    case "failed":
      console.log(result.error);
      break;

    case "pending":
      console.log(result.estimatedTime);
      break;
  }
}

The type field acts as the discriminator.

Best Practice 1: Always Use a Single Discriminator Property

Common discriminator names:

type
kind
status
variant

Example

type Shape =
  | { type: "circle"; radius: number }
  | { type: "square"; size: number };

Avoid multiple discriminators like { kind: "circle", shape: "circle" }.
Stick to one clear property.

Best Practice 2: Use switch Instead of Multiple if Statements

switch statements improve readability and maintainability.

Bad

if (shape.type === "circle") {}
if (shape.type === "square") {}

Better

switch (shape.type) {
  case "circle":
    // …
    break;

  case "square":
    // …
    break;
}

This also enables exhaustive type checking.

Best Practice 3: Use Exhaustive Checks

A powerful TypeScript technique.

function assertNever(x: never): never {
  throw new Error("Unexpected type");
}

Example

switch (shape.type) {
  case "circle":
    // …
    break;

  case "square":
    // …
    break;

  default:
    assertNever(shape);
}

If a new type is added (e.g., { type: "triangle" }), TypeScript throws an error immediately, preventing silent bugs.

Best Practice 4: Avoid Optional Fields in Union Types

Bad design

type ApiResponse = {
  status: "success" | "error";
  data?: string[];
  error?: string;
};

This creates unclear states.

Better

type ApiResponse =
  | { status: "success"; data: string[] }
  | { status: "error"; error: string };

Now the type system enforces valid states only.

Best Practice 5: Use Discriminated Unions for UI State

type LoadingState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string };

Best Practice 5 – Use Discriminated Unions for UI State

type ApiResponse =
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; message: string };

Usage in UI

switch (state.status) {
  case "loading":
    return "Loading...";

  case "success":
    return state.data;

  case "error":
    return state.message;
}

This prevents invalid UI states.

Best Practice 6 – Create Reusable Type Guards

Instead of repeating logic everywhere, define a guard once and reuse it.

Example

function isError(res: ApiResponse): res is { status: "error"; message: string } {
  return res.status === "error";
}

Usage

if (isError(response)) {
  console.log(response.message);
}

Reusable guards improve clean architecture.

Best Practice 7 – Keep Union Types Small and Focused

Avoid extremely large unions (e.g., “50 different variants”).
Break them into logical groups.

Example

// Separate concerns
type UserState = { /* ... */ };
type OrderState = { /* ... */ };
type PaymentState = { /* ... */ };

This keeps code maintainable.

Common Mistakes Developers Make

  1. Using any

    function handle(res: any) { /* ... */ }

    Why it’s bad: Removes TypeScript safety.

  2. Forgetting Exhaustive Checks

    Developers often forget to handle new union cases.
    Always use an assertNever helper:

    function assertNever(x: never): never {
      throw new Error(`Unexpected object: ${x}`);
    }
  3. Mixing Unrelated Unions

    type Result =
      | { type: "user" }
      | { type: "product" }
      | { type: "error" };

    Why it’s bad: Combines unrelated domain concerns.
    Solution: Separate them into distinct types.

Why Discriminated Unions Are Powerful

They help you:

  • Prevent impossible states
  • Write self‑documenting code
  • Catch errors at compile time
  • Improve maintainability in large codebases

Discriminated unions are heavily used in:

  • Angular state management
  • Redux / NgRx
  • API response modeling
  • Domain‑driven design

Final Thoughts

Discriminated unions + type guards are among the most powerful patterns in TypeScript.

They let you model real‑world state transitions safely, while keeping your code readable and scalable.

If you’re building large TypeScript applications, mastering this pattern will significantly improve your code quality and reliability.

0 views
Back to Blog

Related posts

Read more »

GPU Flight — System Architecture

GPU Flight Architecture Overview The previous post covered thread divergence at the SASS level. Before diving into other optimization strategies, it helps to r...