ABHIJAT
← Back to Writing

Frontend

Narrowing unions without a wall of type guards

Discriminated unions do most of the narrowing work for you, if the union is shaped to let them.

Abhijat2026-084 min read4 views

Last updated September 17, 2026

A common pattern in TypeScript code that hasn't fully embraced unions yet: a type that's a plain union of shapes, handled with a chain of typeof and in checks to figure out which shape you actually have at runtime.

type Event = { type: string; payload: unknown };

function handle(e: Event) {
  if (e.type === "click") {
    // payload is still `unknown` here — TypeScript can't narrow it
  }
}

The fix isn't more guards, it's giving the union a proper discriminant that TypeScript can actually key off of.

Shaping the union to narrow itself

type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; deltaY: number };

function handle(e: Event) {
  switch (e.type) {
    case "click":
      console.log(e.x, e.y); // narrowed to the click variant, fully typed
      break;
    case "keypress":
      console.log(e.key); // narrowed to the keypress variant
      break;
  }
}

With a literal type field as the discriminant, a single switch (or if (e.type === "click")) narrows e to the exact variant inside that branch — no manual casting, no in checks, no typeof payload === "object" guards standing in for what the type system should be doing.

Exhaustiveness as a bonus

The other benefit that comes for free: a default case that assigns to a never-typed variable catches unhandled variants at compile time.

function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}

switch (e.type) {
  case "click": /* ... */ break;
  case "keypress": /* ... */ break;
  case "scroll": /* ... */ break;
  default: assertNever(e); // compile error if a new variant is added and not handled
}

Add a fourth event variant later, forget to handle it in this switch, and the build fails at the assertNever call instead of silently falling through at runtime. That's the actual payoff of a discriminated union over a plain one — not less typing today, but a compiler that catches the thing you forgot, instead of a runtime bug that shows up later.

Tags

TypeScriptFrontend