Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Discriminated Unions and Tagged Types in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Literal Types and Const Assertions in TypeScript
TypeScript
7m
Apr 19, 2026

Literal Types and Const Assertions in TypeScript

A practical guide to TypeScript literal types and as const — narrowing values to exact possibilities, building type-safe enumerations, and preserving tuple and object shapes.

#Literal Types#Const Assertions+5
Union Types: Multiple Possibilities in TypeScript
TypeScript
7m
Apr 5, 2026

Union Types: Multiple Possibilities in TypeScript

A practical guide to TypeScript union types — combining multiple types with OR logic, accessing shared properties, and narrowing unions safely at runtime.

#Union Types#Type Narrowing+5
Type Guards: Runtime Type Checking in TypeScript
TypeScript
9m
Apr 26, 2026

Type Guards: Runtime Type Checking in TypeScript

TypeScript knows about types at compile time, but values arrive at runtime. Type guards bridge that gap — narrowing a broad type to a specific one so TypeScript can help you handle each case correctly.

#Type Guards#Type Narrowing+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Are Discriminated Unions
  • The Discriminant Property
  • Exhaustive Checking with never
  • Modelling Application State
  • Nested Discriminated Unions
  • Real-World Patterns
  • Best Practices
  • Key Takeaways
TypeScript

Discriminated Unions and Tagged Types in TypeScript

May 3, 2026•8 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript discriminated unions and tagged types
Discriminated Unions and Tagged Types in TypeScript

What Are Discriminated Unions

A regular union type like string | number forces you to use type guards — typeof, instanceof, or custom predicates — to figure out which branch you're in. That works, but it scales poorly when your union has many members with complex shapes.

Discriminated unions solve this with a simple convention: every member of the union shares a property with the same name but a different literal type. That shared property is the discriminant (sometimes called a tag). TypeScript checks the discriminant at compile time and uses it to narrow the full type automatically — no extra type guard functions needed.

In the last post, you briefly saw this pattern with status: "loading" | "success" | "error". In this post you'll go deeper: building precise state models, enabling exhaustive checks that catch every missing case, and applying the pattern to real-world scenarios like async data, event systems, and command handlers.

Also Called Tagged Unions

Discriminated unions go by several names: tagged unions, disjoint unions, and algebraic data types (ADTs). All refer to the same concept — a union where each member carries a unique identifier that allows safe narrowing. The term "tagged" comes from the idea that each variant is tagged with a literal that names it.

The Discriminant Property

A discriminated union needs three things: a union type, a shared property on every member, and a unique literal type for that property on each member.

Here's the simplest possible example:

type Circle = {
  kind: "circle";
  radius: number;
};
 
type Rectangle = {
  kind: "rectangle";
  width: number;
  height: number;
};
 
type Triangle = {
  kind: "triangle";
  base: number;
  height: number;
};
 
type Shape = Circle | Rectangle | Triangle;

kind is the discriminant. Each member has it, and each has a unique literal value. Now TypeScript can narrow on it:

function area(shape: Shape): number {
  if (shape.kind === "circle") {
    // TypeScript knows: shape is Circle
    return Math.PI * shape.radius ** 2;
  }
  if (shape.kind === "rectangle") {
    // TypeScript knows: shape is Rectangle
    return shape.width * shape.height;
  }
  // TypeScript knows: shape is Triangle
  return 0.5 * shape.base * shape.height;
}

No as casts, no custom type predicates, no in checks. TypeScript narrows Shape to Circle, Rectangle, or Triangle purely on the equality check shape.kind === "circle". Access a property that doesn't exist on the narrowed type and TypeScript flags it immediately.

Switch statements work equally well and often read more clearly for discriminated unions:

function describe(shape: Shape): string {
  switch (shape.kind) {
    case "circle":
      return `Circle with radius ${shape.radius}`;
    case "rectangle":
      return `Rectangle ${shape.width}×${shape.height}`;
    case "triangle":
      return `Triangle with base ${shape.base} and height ${shape.height}`;
  }
}

Exhaustive Checking with never

The real power of discriminated unions reveals itself when you add a new member. TypeScript can tell you every place in your codebase that doesn't handle it yet — at compile time, before you ship.

The technique uses the never type. At the end of a switch statement, after all known cases are handled, the remaining type should be never — meaning no value can reach that point. If it isn't never, you've missed a case:

function assertNever(value: never): never {
  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      return assertNever(shape); // Error if any case is missing
  }
}

Now add a new member to Shape:

type Ellipse = {
  kind: "ellipse";
  semiMajor: number;
  semiMinor: number;
};
 
type Shape = Circle | Rectangle | Triangle | Ellipse;

TypeScript immediately reports an error on the assertNever(shape) line: Argument of type 'Ellipse' is not assignable to parameter of type 'never'. The compiler pinpoints every switch statement that needs updating. No grep, no runtime crash — just a compiler error pointing you directly to the gap.

assertNever Doubles as a Runtime Safety Net

The assertNever function isn't just a type trick — it also throws at runtime if a value reaches the default branch. This can happen when data comes from an external source (API, database) and contains a variant your code doesn't recognise yet. The thrown error surfaces the problem immediately rather than silently producing wrong output.

Modelling Application State

Discriminated unions are one of the best tools for modelling asynchronous state. The traditional approach — boolean flags like isLoading, isError, isSuccess — allows impossible combinations: what does isLoading: true, isError: true mean? Discriminated unions make impossible states unrepresentable:

type AsyncData<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };
 
// Usage with a specific type
type UserState = AsyncData<User>;
 
function renderUser(state: UserState): string {
  switch (state.status) {
    case "idle":
      return "No data loaded yet";
    case "loading":
      return "Loading...";
    case "success":
      // TypeScript knows: state.data is User
      return `Welcome, ${state.data.name}`;
    case "error":
      // TypeScript knows: state.error is Error
      return `Failed: ${state.error.message}`;
    default:
      return assertNever(state);
  }
}

data only exists when status === "success". error only exists when status === "error". You can never accidentally access state.data in the loading branch — TypeScript won't allow it.

Make Impossible States Unrepresentable

This phrase — coined by Richard Feldman — captures why discriminated unions matter. When boolean flags represent state, you can set contradictory combinations. When a discriminated union represents state, each variant is its own type. The invalid combinations don't exist in the type system, so you can't accidentally create them.

Nested Discriminated Unions

Discriminated unions compose well. You can nest them to model more complex scenarios — for example, a network request that can fail in different ways:

type NetworkError =
  | { kind: "timeout"; durationMs: number }
  | { kind: "unauthorized"; redirectTo: string }
  | { kind: "notFound"; resource: string }
  | { kind: "serverError"; statusCode: number; message: string };
 
type FetchResult<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: NetworkError };
 
function handleFetch<T>(result: FetchResult<T>): void {
  if (result.status === "error") {
    // TypeScript knows: result.error is NetworkError
    switch (result.error.kind) {
      case "timeout":
        console.log(`Timed out after ${result.error.durationMs}ms`);
        break;
      case "unauthorized":
        window.location.href = result.error.redirectTo;
        break;
      case "notFound":
        console.log(`Resource not found: ${result.error.resource}`);
        break;
      case "serverError":
        console.log(`${result.error.statusCode}: ${result.error.message}`);
        break;
      default:
        assertNever(result.error);
    }
  }
}

Each level of the nested union is narrowed independently. TypeScript tracks both result.status and result.error.kind without losing information.

Real-World Patterns

Discriminated unions shine in event-driven systems, command handlers, and anywhere you process different variants of a message:

type UserEvent =
  | { type: "user_registered"; userId: string; email: string; timestamp: Date }
  | {
      type: "user_updated";
      userId: string;
      changes: Partial<{ email: string; name: string }>;
    }
  | { type: "user_deleted"; userId: string; deletedAt: Date }
  | { type: "password_reset"; userId: string; token: string; expiresAt: Date };
 
function handleUserEvent(event: UserEvent): void {
  switch (event.type) {
    case "user_registered":
      sendWelcomeEmail(event.email);
      createUserRecord(event.userId, event.email);
      break;
    case "user_updated":
      updateUserRecord(event.userId, event.changes);
      break;
    case "user_deleted":
      softDeleteUser(event.userId, event.deletedAt);
      break;
    case "password_reset":
      sendPasswordResetEmail(event.userId, event.token, event.expiresAt);
      break;
    default:
      assertNever(event);
  }
}

Each event variant carries exactly the data it needs — nothing more, nothing less. The handler function is a single, exhaustive switch. Add a new event type to UserEvent and every handler that doesn't cover it will fail to compile.

The Discriminant Must Be a Literal Type

The discriminant property must have a literal type — "circle", 42, true — not a broad type like string or number. TypeScript needs an exact value to narrow on. If kind: string, TypeScript can't tell which variant you're in when you check shape.kind === "circle", because any string member could theoretically have that value.

Best Practices

Name your discriminant consistently. Across a codebase, stick to one or two conventions: type, kind, or status are common choices. Mixing conventions across different unions makes code harder to scan.

Put the discriminant first. It's conventional to declare the tag property first in each member type — it signals the pattern to readers immediately.

Prefer narrow variants over optional properties. Instead of one type with many optional fields, use a discriminated union with specific variants:

// Avoid — optional fields allow impossible combinations
type Notification = {
  type: "email" | "sms" | "push";
  email?: string; // only for "email" type
  phoneNumber?: string; // only for "sms" type
  deviceToken?: string; // only for "push" type
  message: string;
};
 
// Prefer — each variant carries exactly what it needs
type Notification =
  | { type: "email"; email: string; message: string }
  | { type: "sms"; phoneNumber: string; message: string }
  | { type: "push"; deviceToken: string; message: string };

Always use assertNever in switch defaults. It costs one line and catches every missed case both at compile time and at runtime.

Key Takeaways

  • A discriminated union is a union where every member shares a property with a unique literal type — the discriminant
  • TypeScript narrows the full type on a simple equality check against the discriminant — no extra type guards needed
  • The assertNever pattern in switch defaults gives you compile-time exhaustiveness checking: add a variant, TypeScript flags every handler that needs updating
  • Discriminated unions make impossible states unrepresentable — each variant carries only the data it needs
  • Use them for async state, event systems, command handlers, and anywhere you switch on a "kind" or "type" field

Your State Modelling Just Got Precise

Discriminated unions are one of the patterns that separates idiomatic TypeScript from TypeScript-flavoured JavaScript. You now have the tools to model state that is impossible to misuse. Next: pattern matching with switch statements — deepening exhaustiveness, handling nested variants, and building utilities that work across any discriminated union.