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.
Walk through an interrupted search
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.
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