DEV Community

Neweraofcoding
Neweraofcoding

Posted on

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

TypeScript is powerful because it helps us write safer code with strong type checking. One of the most useful patterns for handling complex data structures is Discriminated Unions combined with Type Guards.

If you’re building large frontend apps (Angular, React) or API-driven applications, this pattern makes your code predictable, readable, and type-safe.

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.

Example: API response states.

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

This is a union type.

But how does TypeScript know which properties exist?

If we try this:

function handleResponse(res: ApiResponse) {
  console.log(res.data);
}
Enter fullscreen mode Exit fullscreen mode

TypeScript throws an error:

Property 'data' does not exist on type 'ApiResponse'
Enter fullscreen mode Exit fullscreen mode

Because not every union member has data.


Solution: Discriminated Unions

A discriminated union uses a common property to identify the type.

In our example:

status
Enter fullscreen mode Exit fullscreen mode

Now TypeScript can narrow types safely.

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

TypeScript automatically knows:

res is { status: "success"; data: string[] }
Enter fullscreen mode Exit fullscreen mode

This is called type narrowing.


What Are Type Guards?

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

Example:

if (res.status === "error")
Enter fullscreen mode Exit fullscreen mode

This acts as a type guard.

But we can also create custom type guards.


Creating Custom Type Guards

Custom guards improve readability and reusability.

Example:

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

Usage:

if (isSuccess(res)) {
  console.log(res.data);
}
Enter fullscreen mode Exit fullscreen mode

Now TypeScript automatically narrows the type.

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 };
Enter fullscreen mode Exit fullscreen mode

Using 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

The type field acts as the discriminator.


Best Practice 1: Always Use a Single Discriminator Property

The most common property names:

type
kind
status
variant
Enter fullscreen mode Exit fullscreen mode

Example:

type Shape =
  | { type: "circle"; radius: number }
  | { type: "square"; size: number };
Enter fullscreen mode Exit fullscreen mode

Avoid multiple discriminators like:

{ kind: "circle", shape: "circle" }
Enter fullscreen mode Exit fullscreen mode

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") {}
Enter fullscreen mode Exit fullscreen mode

Better:

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

  case "square":
    break;
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

Example:

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

  case "square":
    break;

  default:
    assertNever(shape);
}
Enter fullscreen mode Exit fullscreen mode

If a new type is added:

{ type: "triangle" }
Enter fullscreen mode Exit fullscreen mode

TypeScript throws an error immediately.

This prevents silent bugs.


Best Practice 4: Avoid Optional Fields in Union Types

Bad design:

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

This creates unclear states.

Better:

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

Now the type system enforces valid states only.


Best Practice 5: Use Discriminated Unions for UI State

This pattern works extremely well for frontend state management.

Example:

type LoadingState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; message: string };
Enter fullscreen mode Exit fullscreen mode

Usage in UI:

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

  case "success":
    return state.data;

  case "error":
    return state.message;
}
Enter fullscreen mode Exit fullscreen mode

This prevents invalid UI states.


Best Practice 6: Create Reusable Type Guards

Instead of repeating logic everywhere.

Example:

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

Usage:

if (isError(response)) {
  console.log(response.message);
}
Enter fullscreen mode Exit fullscreen mode

Reusable guards improve clean architecture.


Best Practice 7: Keep Union Types Small and Focused

Avoid extremely large unions like:

50 different variants
Enter fullscreen mode Exit fullscreen mode

Break them into logical groups.

Example:

UserState
OrderState
PaymentState
Enter fullscreen mode Exit fullscreen mode

This keeps code maintainable.


Common Mistakes Developers Make

1. Using any

Bad:

function handle(res: any)
Enter fullscreen mode Exit fullscreen mode

This removes TypeScript safety.


2. Forgetting Exhaustive Checks

Developers often forget to handle new union cases.

Always use:

assertNever()
Enter fullscreen mode Exit fullscreen mode

3. Mixing unrelated unions

Bad:

type Result =
  | { type: "user" }
  | { type: "product" }
  | { type: "error" }
Enter fullscreen mode Exit fullscreen mode

Separate domain concerns.


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

This is why discriminated unions are heavily used in:

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


Final Thoughts

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

They allow you to 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.


Top comments (1)

Collapse
 
cedricpierre profile image
Cédric Pierre

One thing that always feels a bit weird to me with discriminated unions is when the object shape changes depending on the discriminator.

From a modeling and API perspective, I often find it cleaner to keep a stable structure and vary only the payload type:

type PaymentResult =
 | { status: "success"; data: PaymentReceipt }
 | { status: "error"; data: PaymentError }
Enter fullscreen mode Exit fullscreen mode

This adds more consistency and keeps responses more predictable while still benefiting from TypeScript’s narrowing.

I think this should be considered a good practice.