Articles

TypeScript Discriminated Unions Explained

A discriminated union tags each variant of a type with a shared literal field, letting TypeScript narrow the type automatically inside a switch or if check.

Takina Takina · · 4 min read
Dark-themed code editor showing TypeScript source

A discriminated union (also called a tagged union) is a TypeScript pattern where each variant of a union type shares a common field — the discriminant — set to a distinct literal value. Checking that field in an if or switch statement lets TypeScript automatically narrow the union down to the specific variant, with full type safety and no manual casting.

It’s one of the most useful patterns in the type system, because it solves a problem plain unions can’t: safely representing “one of several distinct shapes” data, like API responses, state machines, or event payloads.

The problem with plain unions

A basic union type says a value is one of several types, but says nothing about how to tell them apart at runtime:

type Result =
  | { data: string }
  | { error: string };

function handle(result: Result) {
  if (result.data) { // Error: Property 'data' does not exist on type '{ error: string }'
    console.log(result.data);
  }
}

TypeScript can’t narrow this safely because both branches structurally overlap — there’s no field guaranteed to exist on one variant but not the other in a way the compiler can use to distinguish them.

Adding the discriminant

A discriminated union fixes this by giving every variant a literal field with a unique value:

type Result =
  | { status: "success"; data: string }
  | { status: "error"; error: string };

function handle(result: Result) {
  if (result.status === "success") {
    console.log(result.data); // narrowed to the success variant
  } else {
    console.log(result.error); // narrowed to the error variant
  }
}

Once the compiler sees result.status === "success", it knows — inside that branch — that result can only be the variant where status is "success", so result.data is valid and result.error isn’t accessible. No type assertions, no as, no runtime type-checking library required.

Switch statements and exhaustiveness checks

Discriminated unions pair naturally with switch, especially for state machines with more than two states:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: string };

function render(state: RequestState): string {
  switch (state.status) {
    case "idle":
      return "Waiting to start";
    case "loading":
      return "Loading...";
    case "success":
      return `Loaded: ${state.data}`;
    case "error":
      return `Failed: ${state.error}`;
    default:
      const exhaustiveCheck: never = state;
      return exhaustiveCheck;
  }
}

That default branch is a common trick: if every case is handled, state has type never inside default, so assigning it to a never-typed variable compiles cleanly. Add a new variant to RequestState later and forget to handle it in the switch, and this line fails to compile — the exhaustiveness check catches the gap at build time instead of leaving a silent runtime hole.

Where this pattern earns its keep

API responses. Modeling a fetch result as { status: "success", data } | { status: "error", error } instead of { data?, error? } makes invalid states — both data and error present, or neither — unrepresentable. The type itself documents the contract.

Reducer actions. A Redux-style reducer’s action union typically discriminates on a type field: { type: "increment" } | { type: "add"; amount: number }. The reducer’s switch statement narrows each action to exactly the payload it carries.

Parser and AST nodes. Compilers and parsers commonly discriminate on a kind field ({ kind: "literal"; value: number } | { kind: "binary"; op: string; left: Node; right: Node }), which is how tools like the TypeScript compiler itself represent syntax trees internally.

Discriminated unions vs enums

It’s worth distinguishing this pattern from a plain enum, since both involve a fixed set of named states:

Discriminated unionEnum
Carries data per variantYes — each variant can have its own fieldsNo — an enum member is just a label
NarrowingAutomatic via the discriminant fieldN/A — doesn’t attach shape to a value
Runtime footprintZero (plain object literals)enum (non-const) compiles to an object
Best forData with distinct shapes per caseA finite set of named constants

If you need a fixed set of states without per-state data, plain union types of string literals or an enum will do. Discriminated unions earn their complexity when each state carries different data.

The takeaway

A discriminated union tags each branch of a union type with a shared literal field, and TypeScript uses that field to narrow the type automatically inside conditionals and switch statements — turning what would otherwise require manual casts or runtime checks into compiler-verified type safety. Reach for this pattern whenever you’re modeling “one of several distinct shapes” data, and add an exhaustiveness check in the default case so new variants can’t slip through unhandled.

Takina Takina · · 4 min read

TypeScript Abstract Classes, Explained

Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.

#TypeScript #JavaScript #Web Development
Takina Takina · · 3 min read

TypeScript's never Type, Explained

never represents values that can't exist — it marks unreachable code, exhaustive switches, and functions that always throw or loop forever.

#TypeScript #JavaScript #Web Development
Takina Takina · · 4 min read

TypeScript readonly Modifiers Explained

TypeScript's readonly keyword blocks reassignment at compile time for properties, arrays, and tuples — with no runtime enforcement at all.

#TypeScript #JavaScript #Web Development