ML
TypeScript

Discriminated Unions That Killed My Bugs

Optional fields are how TypeScript codebases drift into nullable-everywhere chaos. Discriminated unions are how they get rescued.

March 22, 20267 min readTypeScriptPatterns

Most TypeScript bugs I find on code review are the same shape. A type is defined with optional fields. Some fields are only meaningful when other fields are set. Nothing enforces the relationship. Six months in, the codebase has foo?.bar?.baz ?? defaultBaz sprinkled defensively everywhere. The fix is older than TypeScript — discriminated unions.

1. The bad shape

type LoadResult = {
  status: "loading" | "success" | "error";
  data?: User;
  error?: Error;
};

The compiler will let you read r.data when r.status === "error". It will let you read r.error when the request succeeded. Every consumer has to defensively check. Every consumer occasionally forgets.

2. The discriminated union

type LoadResult =
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: Error };

Same vocabulary. The compiler now refuses you:

function render(r: LoadResult) {
  if (r.status === "success") {
    return r.data.name; // OK — r.data is User
  }
  if (r.status === "error") {
    return r.error.message; // OK — r.error is Error
  }
  return "loading...";
  // and trying r.data here would be a compile error
}

The discriminator (status) is the field every variant has, with a literal type that distinguishes them. TypeScript narrows by it automatically.

3. Exhaustiveness checks

Add a new variant later. Every consumer should be updated. The compiler can guarantee that for you:

function render(r: LoadResult): string {
  switch (r.status) {
    case "loading": return "...";
    case "success": return r.data.name;
    case "error":   return r.error.message;
    default:
      const _exhaustive: never = r;
      return _exhaustive;
  }
}

If you add { status: "cancelled" } to the union, the never assignment fails everywhere you forgot to handle it. The compiler hands you the bug list. This single pattern has caught more of my bugs than any test suite.

4. Where this shines

  • API responses (success / error / loading).
  • Domain state machines (draft / submitted / approved / rejected).
  • Event payloads (UserCreated / UserUpdated / UserDeleted), with the payload shape varying.
  • Anywhere "this field is only present when X" was your previous comment.

5. Idiomatic patterns

5.1 The constructor functions

const Result = {
  loading:    (): LoadResult => ({ status: "loading" }),
  success:    (data: User): LoadResult => ({ status: "success", data }),
  failure:    (error: Error): LoadResult => ({ status: "error", error }),
};

Callers write Result.success(user) instead of literal objects. Cheaper to refactor, more readable in tests.

5.2 The narrowing helpers

function isSuccess(r: LoadResult): r is Extract<LoadResult, { status: "success" }> {
  return r.status === "success";
}

Useful when you want a one-line filter or guard outside of a switch.

6. When NOT to use this

  • For variants that differ only in values, not shape. { kind: "small" | "large", size: number } is fine as-is.
  • When the discriminator would be artificial. Don't invent a kind field that nobody else needs just to satisfy the pattern.

The mental model

Optional fields say "this might be there." Discriminated unions say "this is exactly there when X." The first is convenient; the second is correct. Reach for the second any time the optional fields are correlated with each other or with a state.

Rules of thumb

  • Anytime you write three or more ?: fields where some are only meaningful together, refactor to a union.
  • Always pair the union with an exhaustiveness check (never assertion).
  • The discriminator name conventions: kind, type, status, tag. Pick one and be consistent.
SharePostLinkedIn

Reader Discussion

1 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Rachel Gold· Staff SREAgrees

    the on-call framing throughout this piece is what makes it land. too many infra articles assume you never get paged. those are written by people who never got paged.

    Mar 25, 2026·3 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email