All posts

1 min read

TypeScript Narrowing Patterns I Use Every Day

Discriminated unions, exhaustive switches, and type predicates — three small patterns that remove almost every 'as' cast from a codebase.

TypeScript

Most TypeScript pain comes from fighting the narrowing engine instead of feeding it. These three patterns cover ninety percent of my day-to-day type work.

1. Discriminated unions over optional fields

A union with a literal _type or status field narrows automatically inside a switch — no casts, no non-null assertions.

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

function render<T>(state: FetchState<T>) {
  switch (state.status) {
    case "success":
      return state.data; // T, guaranteed
    case "error":
      return state.error.message; // Error, guaranteed
  }
}

2. Exhaustiveness with never

Assigning the leftover case to a never binding turns 'I forgot a case' from a runtime bug into a compile error — the cheapest regression test you will ever write.

3. Type predicates for the boundary

At API boundaries, a small value is Post predicate keeps the unsafe cast in exactly one auditable place instead of scattered across the app.

If you find yourself writing 'as', ask what evidence the compiler is missing — then write code that produces that evidence.