Discriminated Unions: Modeling State That Can't Be Wrong
Photo: Unsplash
Almost every component that fetches data starts with the same three fields: isLoading, error, and data. It looks reasonable until you count the combinations. Three independent fields give eight possible states, and most of them are nonsense. Loading while also holding an error and data? Done loading with neither data nor error? Your UI has to defensively check for these impossible combinations, and the bugs that slip through are exactly the ones that only appear at 2am in production.
Discriminated unions fix this at the type level. Instead of modeling state as a bag of optional fields, you model it as a closed set of mutually exclusive shapes, each tagged with a common literal property. The compiler then refuses to let you read a field that does not exist in the current state. Impossible states stop being something you remember to guard against and become something that cannot be written down.
The bag-of-fields anti-pattern
Here is the shape almost everyone writes first, and the bug it invites.
interface RequestState<T> {
isLoading: boolean;
error?: Error;
data?: T;
}
function render(state: RequestState<User>) {
if (state.isLoading) return "Loading...";
// TypeScript thinks data might still be undefined here.
return state.data!.name; // The ! is a lie waiting to crash.
}The non-null assertion is the tell. You are overriding the compiler because the type allows a state, "not loading but no data," that your logic assumes never happens. Nothing enforces that assumption. A future refactor can produce exactly that state and the type system will say nothing.
Tagging each state
A discriminated union gives every variant a shared literal field, the discriminant, and only the fields that make sense for that variant.
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };There is no way to construct a loading state that also carries data, because the loading variant simply has no data field. There is no way to be in success without data, because data is required there. The eight-state mess collapses to exactly four legal states, and each one carries precisely the data it needs. This is the core idea behind "make impossible states unrepresentable," and the mechanics are documented in the TypeScript handbook section on unions and narrowing.
Narrowing pays you back
The payoff comes when you read the state. Checking the discriminant narrows the type, so the compiler knows exactly which fields are available in each branch.
function render(state: RequestState<User>): string {
switch (state.status) {
case "idle":
return "Start a search";
case "loading":
return "Loading...";
case "success":
return state.data.name; // data is guaranteed to exist
case "error":
return state.error.message; // error is guaranteed to exist
}
}No optional chaining, no non-null assertions, no defensive checks. Inside case "success" the type of state is narrowed to the success variant, so state.data is a plain User. Try to read state.error there and it is a compile error, because that field does not exist on the narrowed type.
The discriminant must be a literal type, like a string or number literal, not a general string. That literal is what lets the compiler tell the variants apart. A boolean works for two-state unions, but named string tags scale better and read more clearly.
Exhaustiveness checking catches the future
The real superpower shows up when you add a new state later. Suppose you add a "refreshing" variant. You want every place that handles state to be forced to account for it. The never type gives you exactly that.
function render(state: RequestState<User>): string {
switch (state.status) {
case "idle": return "Start a search";
case "loading": return "Loading...";
case "success": return state.data.name;
case "error": return state.error.message;
default: {
// If a new variant is added, `state` is no longer `never` here,
// and this assignment becomes a compile error.
const exhaustive: never = state;
return exhaustive;
}
}
}Because every handled case narrows the union, by the time control reaches default the type of state is never, and assigning never to never is fine. Add a variant without handling it and state is no longer never, so the assignment fails to compile. Your type system now hands you a punch list of every switch statement that needs updating. The never type is described in the handbook, and this pattern is the single most valuable reason to adopt discriminated unions.
Beyond data fetching
This pattern is not just for network requests. Any value that can be in one of several mutually exclusive shapes benefits: form fields that are pristine, editing, or invalid; a payment that is pending, captured, or refunded; a websocket message where the type field determines the payload.
type WsMessage =
| { type: "chat"; text: string; userId: string }
| { type: "typing"; userId: string }
| { type: "presence"; online: string[] };
function handle(msg: WsMessage) {
if (msg.type === "chat") {
// msg.text and msg.userId are available; msg.online is not.
console.log(`${msg.userId}: ${msg.text}`);
}
}The type field on websocket and event payloads is a discriminant you already have; you just need to model it as a union instead of one wide interface with everything optional. The moment you do, every consumer gets precise narrowing for free.
Takeaways
- Independent boolean and optional fields multiply into impossible states your code must guard.
- A discriminated union is a set of variants sharing one literal discriminant field.
- Each variant carries only its valid data, so impossible combinations cannot be constructed.
- Checking the discriminant narrows the type, removing optional chaining and non-null assertions.
- A
neverassignment in the default branch enforces exhaustiveness when new states are added. - The pattern applies to any mutually exclusive state: requests, forms, payments, and event payloads.

