Jul 23, 2026 · 5 min read

Making invalid states unrepresentable in TypeScript

Most of the bugs I've chased in TypeScript codebases weren't logic errors — they were state errors. A view that's somehow "loading" and "has an error" at the same time. An object with a status of "success" but a null payload. The types allowed a combination that should never exist, and given enough time, the code produced it.

The fix is a principle I keep coming back to: make invalid states unrepresentable. If a combination can't be constructed, you never have to guard against it — or forget to.

From loose flags to a discriminated union

The usual shape is a bag of optional fields: { isLoading: boolean; error?: Error; data?: User }. That type permits eight combinations, and only three are real — loading, failed, loaded. The other five are bugs waiting to happen.

A discriminated union collapses it to exactly the legal states:

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

Now there's no way to be "loading with an error." When you switch on status, TypeScript narrows the type inside each branch, so data only exists where it actually makes sense — and the compiler makes you handle every case.

Where it pays off

Anywhere state is richer than a single boolean: async requests, multi-step forms, feature flags, payment flows. On the platforms I've built, modeling these as unions erased an entire class of "impossible" runtime states and made the code read like the spec instead of defending against itself.

The discipline costs a few extra lines up front. In return you get a compiler that refuses to let the illegal state exist at all — a much better place to catch it than production.