Discriminated unions cheatsheet.

The pattern

type Result<T> =
  | { kind: "ok"; value: T }
  | { kind: "err"; error: string };

A union of objects sharing a literal field (the discriminator). TS narrows on it.

Common discriminator names

  • kind / type / tag / status — pick one per project and stick to it.

Switch on discriminator

function unwrap<T>(r: Result<T>): T {
  switch (r.kind) {
    case "ok": return r.value;
    case "err": throw new Error(r.error);
  }
}

Exhaustive check

function f(r: Result<string>): string {
  switch (r.kind) {
    case "ok": return r.value;
    case "err": return `error: ${r.error}`;
    default: {
      const _: never = r;
      throw new Error("unreachable");
    }
  }
}

Add a variant; TS errors on the _: never line.

Modeling loading state

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(s: FetchState<User>) {
  switch (s.status) {
    case "idle": return null;
    case "loading": return <Spinner />;
    case "success": return <User user={s.data} />;
    case "error": return <Error msg={s.error.message} />;
  }
}

Forces you to handle every state. Much safer than { loading: bool; error: Error | null; data: T | null } (16 combos vs 4).

Result type (no exceptions)

type Ok<T> = { kind: "ok"; value: T };
type Err<E> = { kind: "err"; error: E };
type Result<T, E = Error> = Ok<T> | Err<E>;

const ok = <T>(v: T): Ok<T> => ({ kind: "ok", value: v });
const err = <E>(e: E): Err<E> => ({ kind: "err", error: e });

function divide(a: number, b: number): Result<number> {
  if (b === 0) return err(new Error("div by zero"));
  return ok(a / b);
}

const r = divide(10, 2);
if (r.kind === "ok") console.log(r.value);
else console.log(r.error);

Modeling events

type DomEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keydown"; key: string }
  | { type: "scroll"; delta: number };

function handle(e: DomEvent) {
  switch (e.type) {
    case "click": console.log(e.x, e.y); break;
    case "keydown": console.log(e.key); break;
    case "scroll": console.log(e.delta); break;
  }
}

Adding common fields

type BaseEvent = { id: string; ts: Date };
type AppEvent = BaseEvent & (
  | { type: "click"; x: number }
  | { type: "scroll"; delta: number }
);

ts-pattern (library)

import { match, P } from "ts-pattern";

const msg = match(result)
  .with({ kind: "ok", value: P.select() }, (v) => `got ${v}`)
  .with({ kind: "err", error: P.select() }, (e) => `oops ${e}`)
  .exhaustive();

Exhaustive matching with destructuring. Worth pulling in for complex unions.

Nested narrowing

type Shape =
  | { kind: "circle"; r: number }
  | { kind: "poly"; points: Array<{ x: number; y: number }> };

function f(s: Shape) {
  if (s.kind === "poly") {
    for (const p of s.points) {
      console.log(p.x, p.y);
    }
  }
}

Generic discriminated unions

type Op<T> =
  | { kind: "set"; value: T }
  | { kind: "merge"; patch: Partial<T> }
  | { kind: "delete" };

function apply<T>(state: T, op: Op<T>): T | undefined {
  switch (op.kind) {
    case "set": return op.value;
    case "merge": return { ...state, ...op.patch };
    case "delete": return undefined;
  }
}

Why prefer over booleans

// BAD: 16 invalid states
type State = { loading: boolean; data?: User; error?: Error };

// GOOD: 4 valid states
type State =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "data"; data: User }
  | { kind: "error"; error: Error };

Make illegal states unrepresentable.

Conversion from REST API shapes

API JSON often uses booleans/nulls. Convert at the boundary:

function fromApi(json: any): FetchState<User> {
  if (json.error) return { status: "error", error: new Error(json.error) };
  if (json.data) return { status: "success", data: json.data };
  return { status: "loading" };
}

Common mistakes

  • Two different discriminator names — kind vs type — narrowing breaks.
  • Discriminator must be a literal type ("ok", not string).
  • Switch without exhaustive check — silent breakage.
  • Putting union inside an optional field — kind? defeats discrimination.
  • Sharing the same union value across variants — ambiguous.

Read this next

If you want my Result + AsyncResult library, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .