Stockio

TypeScript Discriminated Unions for UI State

Replace fragile boolean flags with discriminated unions, exhaustive switches, and explicit transitions that make loading, success, and failure states easier to maintain.

By Daniel Kim

React Developer ·

On this page

A results panel often starts with three independent fields: loading, data, and error. Soon the interface can represent loading with an old error, success without data, or an empty result that looks like failure. TypeScript discriminated unions let you describe the combinations that actually mean something instead of asking every consumer to reconstruct your intentions.

Start with the states your application permits

Write down the observable states before writing a type. A search panel might be idle, pending, resolved, or rejected. Each state should answer two questions: what can the renderer assume, and what events may happen next? This is state modeling, not merely a shorter way to annotate an object.

Use the JSON formatter to inspect representative state snapshots. Paste an idle snapshot, a populated result, and a failure separately, then compare their fields. The formatter helps expose contradictions; it does not prove that transitions are valid or execute TypeScript.

Avoid treating every network response field as application state. A response may contain transport metadata that no component needs. Keep the state model focused on facts the interface uses, and give every variant a literal discriminant such as status.

Define variants with required payloads

The central technique is a union of objects whose discriminator values are distinct literals. TypeScript can then narrow the whole object after checking that property. The TypeScript narrowing handbook explains the underlying control-flow analysis.

type Item = { id: string; label: string };
type SearchState =
| { status: "idle" }
| { status: "pending"; query: string }
| { status: "resolved"; query: string; items: Item[] }
| { status: "rejected"; query: string; message: string };

function countLabel(state: SearchState): string {
if (state.status === "resolved") {
  return String(state.items.length);
}
return "No result count";
}

In the resolved branch, items is available without an assertion. In idle, it does not exist. That distinction is stronger than declaring an optional items array next to a boolean loading flag.

A resolved result containing an empty array is still a successful response. Model it separately only if empty results have meaningfully different behavior. Every additional variant creates cases that renderers, tests, and transitions must handle. Prefer the smallest useful union, rather than translating every visible phrase into another state.

Make missing branches a compiler error

A switch statement documents the rendering decision, but a default branch that returns an empty string can hide newly added states. Instead, route the default branch through a function accepting never.

function unreachable(value: never): never {
throw new Error("Unhandled state");
}

function describe(state: SearchState): string {
switch (state.status) {
  case "idle": return "Enter a query";
  case "pending": return "Searching";
  case "resolved": return countLabel(state);
  case "rejected": return state.message;
  default: return unreachable(state);
}
}

If you later add a cancelled variant, the default branch receives that variant rather than never, producing a type error. This is an exhaustiveness check. It works only when you preserve the union information instead of widening the value to an unrelated structural type.

Do not silence this error with as never. That removes the reason for having the check. Add the missing behavior and decide whether cancellation looks like idle, retains results, or deserves its own message. The compiler can identify missing branches; it cannot choose product behavior.

Treat events and transitions as a separate contract

A state union prevents impossible object shapes, but it does not automatically prevent every invalid transition. A function can still replace resolved state with pending state whenever it wants. If transition rules matter, centralize them in a reducer or dedicated functions.

For example, let a search-started event carry a query, and let a search-completed event carry a request identifier plus results. Store the active identifier while pending. Ignore completion events whose identifier no longer matches. This handles stale responses, which a discriminant alone cannot solve.

Keep event names distinct from state names. Started is something that happened; pending is the current condition. This distinction makes logs and tests easier to understand. It also avoids inventing events that contain arbitrary replacement state and bypass your transition rules.

For larger models, TypeScript utility types can extract a particular variant with Extract. Use that to constrain a helper to resolved state rather than making the helper recheck every case. Keep the public API readable; deeply nested conditional types often cost more than a few explicit aliases.

Test the model at both boundaries

Type checking and runtime testing answer different questions. A compile-time fixture can demonstrate that a rejected state without a message is illegal. A runtime test can demonstrate that an older request cannot overwrite newer results. You need both when the behavior matters.

External input remains untrusted data even when your internal union is precise. A parsed storage entry or incoming message must be checked before it becomes SearchState. A cast does not turn arbitrary objects into legal variants. Consult the TypeScript object types guide when defining the public shape, then validate the boundary explicitly.

Test every state with a realistic renderer fixture. Include an empty result, a long error message, repeated start events, and completion after cancellation. Check that an old error disappears when a fresh search begins unless preserving it is an intentional decision.

Keep examples alongside related JavaScript and TypeScript guides, and use the wider development resources when integrating the state machine with browser APIs. The important review question is not whether the union looks elegant. It is whether a new developer can determine which facts are guaranteed in each branch without reading every caller.

Start with idle, then submit query A and enter pending with request identifier 1. Before it finishes, submit query B and replace pending with identifier 2. When request 1 succeeds, the reducer should leave the state unchanged. When request 2 succeeds, it should enter resolved with B's query and items. This sequence is a compact regression test for the difference between a legal state shape and a legal transition.

Decide how refresh behaves before adding more fields. If the interface must show old results while fetching new ones, a pending variant containing only a query no longer describes the screen. Introduce an explicit refreshing variant with the previous items, or give pending a deliberately modeled previous-result payload. Do not restore independent loading and data flags simply because the first union was too small.

Review transition invariants in plain language alongside the reducer: starting a fresh search clears an obsolete error; an outdated completion never changes visible results; cancellation cannot let an earlier request resurrect the panel. Then test those statements as event sequences, not just isolated snapshots. If an event is invalid for the current state, choose whether it is ignored, recorded diagnostically, or rejected. Making that policy explicit prevents accidental behavior from becoming the application's undocumented state machine.

Key takeaways

  • Use one discriminant property with distinct literal values.
  • Require the payload each variant actually needs.
  • Make switches exhaustive with never.
  • Validate external values before accepting internal state.
  • Test transition behavior separately from object shapes.
Featured sponsor

Reach designers & developers at work

Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.

Become a sponsor

Keep reading

Related articles

More in JavaScript & Frameworks
JavaScript & Frameworks

10 Best JavaScript Charting Libraries

Compare ten JavaScript chart libraries for dashboards, reports, and interactive data stories. See where each excels, what it costs, and how to choose based on your app and team.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

8 Best React State Management Libraries

Find a React state approach that fits your app, from built-in hooks to dedicated stores, with clear trade-offs around data flow, debugging, async work, and learning effort.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

12 Best and Most Popular JavaScript Libraries

Compare twelve widely used JavaScript libraries for interfaces, data, testing, and everyday development, with clear strengths, trade-offs, and advice for choosing only what your project needs.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

localStorage vs sessionStorage vs IndexedDB Compared

Choose browser storage by data lifetime, size and access pattern. Compare synchronous Web Storage with IndexedDB transactions, migrations, quotas and failure handling.

Includes: JSON formatterSofia MarchettiSofia Marchetti
JavaScript & Frameworks

React Server Components Explained for Developers

Learn what React Server Components execute, what crosses the client boundary, and how to structure data access, interactive widgets, and mutations without leaking private data.

Includes: JSON formatterDaniel KimDaniel Kim
JavaScript & Frameworks

useMemo vs useCallback vs React.memo: Fewer Rerenders

Learn when React memoization helps, how prop identity affects rerenders, and why profiling and state placement should come before useMemo and useCallback.

Includes: JSON formatterDaniel KimDaniel Kim