Discriminated unions
Model state with TypeScript discriminated unions so invalid combinations cannot be written down, the first of this chapter's bug-class moves.
A request goes out. While it’s in flight you want a spinner, on success the data, on failure an error banner. The shape that suggests itself looks harmless, and you’ll find it shipped in real codebases.
type RequestState = { isLoading: boolean; data?: User; error?: Error;};
const renderUser = (state: RequestState): string => { if (state.isLoading) return 'Loading…'; if (state.error) return state.error.message; return state.data.name;};Both the type and the function compile. A boolean plus two optional fields draws no complaint.
const state: RequestState = { isLoading: false,};
renderUser(state);Some code path produces this value: a reset that cleared data and error but left isLoading unset, or a store that defaults every field. With isLoading false and no error, the first two branches fall through and the function reads state.data.name, but state.data is undefined, so the page crashes.
The compiler accepted every combination of those fields, including ones the runtime can never legally produce. There are only four valid states, idle, loading, success, and error, and a value with isLoading: false and neither data nor error is none of them. But the type said it could exist, so the renderer read state.data without first proving it was there, and somewhere a piece of state-management code produced exactly the value the type had signed off on. The fix isn’t more care in the consumer. It’s structural: make the impossible states unrepresentable, so the bad value can’t be written in the first place.
Architectural Principle #7: model with discriminated unions so impossible states cannot be written down. This lesson establishes it, and the rest of the chapter builds on it.
The combinatorial mismatch
Section titled “The combinatorial mismatch”The flag-set shape declares three independent fields: a boolean and two optionals. And “data present” is not one state but many, since data can be any User value. Cross the four (isLoading, error) rows against a data column for undefined and three for distinct users, and the type signs off on sixteen combinations.
error: undef
error: Error
error: undef
error: Error
The runtime only ever produces four of them: idle, loading, success, and error. The other twelve are values the runtime should never create, yet the type { isLoading: boolean; data?: User; error?: Error } accepts them all, so every consumer has to defend against all sixteen. The discriminated-union shape narrows the type back to the four real states.
The discriminated-union shape
Section titled “The discriminated-union shape”A discriminated union is a union of object types where every variant carries the same literal-typed field, the discriminant . The compiler tracks that field through runtime checks and narrows the value to the matching variant inside the branch.
The canonical request-state shape:
type RequestState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; error: Error };Four variants, discriminated by status. Each result lives on the variant where it’s valid: data on success, error on error. Nothing pairs status: 'loading' with a data field, or status: 'error' with no error. The 12 impossible states from the flag-set shape can no longer be written down; the compiler refuses them at the literal site.
Reading a value of this type means narrowing on the discriminant first:
const renderUser = (state: RequestState): string => { if (state.status === 'success') { return state.data.name; } return 'No user yet';};Outside the if block, data doesn’t exist on the type at all, so reading it on a non-success variant fails at compile time rather than at render time. The bug from the introduction can no longer compile.
Conventions for the discriminant: status, kind, type
Section titled “Conventions for the discriminant: status, kind, type”Any literal-typed key works as the discriminant; the compiler narrows on all of them. But three names cover almost every case, and picking the right one signals what you’re modeling.
status is for async or request lifecycles, a value moving through idle, loading, success, and error as a request progresses. The running example uses it for exactly that, and the same name shows up later in TanStack Query.
kind is the general-purpose taxonomy discriminant: the variants aren’t a lifecycle but “this thing can be one of several different things.” A polymorphic UI component that renders as either a button or a link uses kind.
type is for event messages, matching vocabulary the platform already uses: event.type on every DOM event is a literal like 'click' or 'submit', and the webhooks you receive from third parties follow the same convention. Reach for type when you’re modeling something that arrives as a message describing what happened.
One exception is worth naming up front: Result<T> uses a boolean discriminant, ok: true | false, not a string. The course committed to that shape in the previous chapter and ships it in lib/result.ts. A boolean fits a two-variant union where one variant is the happy path, and if (result.ok) reads as the intent at every call site. Everywhere else, prefer string literals: they survive JSON serialization across the wire, they read clearly in DevTools where a 0 or 1 would leave you guessing, and they don’t collide with truthy/falsy short-circuiting.
Four canonical SaaS shapes
Section titled “Four canonical SaaS shapes”The discriminated-union shape recurs in four places, each at a layer boundary where data crosses from one part of the system to another and the type must refuse impossible combinations.
The first is the request state built earlier in this lesson.
type RequestState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; error: Error };This is the shape every async lifecycle takes. The idle variant earns its place when the request waits on the user, such as a search box waiting for input or a button not yet clicked; when the request fires on mount, drop idle and start at loading.
The second is Result<T>, the return shape for expected failure. When a function can fail in known ways (validation, not-found, unauthorized) and the caller is meant to handle the failure rather than catch a throw, it returns a Result<T>.
type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: string; userMessage: string } };Read Result<User> as a result that, on success, carries a User. The rule the shape encodes: throw the unexpected, return the expected.
The third is the event message, the shape every webhook handler, reducer, and queue consumer reads.
type AppEvent = | { type: 'user.created'; userId: string } | { type: 'invoice.paid'; invoiceId: string; amount: number } | { type: 'subscription.canceled'; subscriptionId: string };Each variant carries exactly the data its event needs: a user.created event has no amount, a subscription.canceled event has no userId. When a new field is needed, you add it to the variant where it belongs rather than to a top-level grab-bag.
The fourth is the UI variant, the shape that lets a polymorphic component refuse invalid prop combinations at the call site.
type ActionProps = | { kind: 'button'; onClick: () => void } | { kind: 'link'; href: string };A button-or-link component that took both onClick and href as optional props would let the caller pass neither (broken UI) or both (which handler wins?). The discriminated shape forces exactly one; the render function narrows on kind and reads the field that belongs to that variant.
Narrowing by the discriminant
Section titled “Narrowing by the discriminant”The narrowing tools from the previous chapter (typeof, ===, in, instanceof, Array.isArray) work on any union. Discriminant equality is the one this pattern relies on, and it shows up in three places.
The first is an if on the discriminant: handle one variant, let the rest pass through. You saw this in the renderUser consumer above.
if (state.status === 'success') { console.log(state.data.name);}Inside the block, state is the success variant; outside, it’s the wider union.
The second is a switch on the discriminant, one case per variant. This is the canonical form for dispatching on a request state at render time.
switch (state.status) { case 'idle': return null; case 'loading': return <Spinner />; case 'success': return <UserCard user={state.data} />; case 'error': return <ErrorMessage error={state.error} />; // exhaustiveness in "Exhaustiveness and narrowing" — a missing variant should fail to compile}Each case narrows state to its variant, so state.data is reachable in 'success' and state.error in 'error'. As the comment notes, a fifth variant added to RequestState would fall through unhandled today; the later “Exhaustiveness and narrowing” lesson makes that a compile error.
The third is equality on the discriminant inside a .filter or .map callback, which you’ll write for arrays of union-typed values.
const successes = states.filter((s) => s.status === 'success');Inside the callback, s narrows to the success variant just as it would in an if. The catch is the return type: successes stays Array<RequestState> rather than narrowing to the success variant, because an inline equality doesn’t flow back through filter’s signature. To narrow the array’s element type, pass a type predicate (the value is T function from the previous chapter’s narrowing lesson), which filter does honor. Use inline equality when you only need narrowing inside the callback body.
Three rules for a well-formed discriminated union
Section titled “Three rules for a well-formed discriminated union”Three rules describe a well-formed discriminated union. Apply them whenever you read or design one.
Every variant must carry the discriminant key with a literal value. Without it on every variant, the compiler can’t tell which variant a value is, and the narrowing falls apart. A union of { status: 'success'; data: User } | { data: User } isn’t a discriminated union; it’s a shape union with nothing to narrow on.
Per-variant fields belong inside the variant where they’re valid. The data field lives on success only, and error lives on error only. Don’t promote a one-variant field to a top-level optional, because that optional is the bug. A field that truly exists on every variant, a requestId for tracing, say, belongs on a wrapping type outside the union rather than copied into each variant.
Keep discriminant literals literal. TypeScript infers literal types for string-literal returns in most positions, so a factory () => ({ status: 'loading' }) needs no ceremony. Watch arrays and reassignable bindings: const states = [{ status: 'loading' }, { status: 'success', data: user }] widens each status to string and breaks the narrowing. Reach for as const, named in the previous chapter, where the literal would otherwise widen.
The running example in both shapes:
type RequestState = { isLoading: boolean; data?: User; error?: Error;};16 combinations admitted; 4 the runtime ever produces. Three independent fields, twelve impossible cells the type approves. Every consumer has to defend against all of them, and missing one is the bug from the introduction.
type RequestState = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; error: Error };4 combinations admitted; 4 the runtime ever produces. Four variants, one per runtime state. The type admits only what the runtime produces, and a consumer can’t read a field the current variant doesn’t have.
Exercise: refactor the flag-set shape
Section titled “Exercise: refactor the flag-set shape”The @ts-expect-error marks the flaw in the flag-set shape: handle reads state.data.name, but the type never promises that field exists. Fix the type and the directive becomes redundant.
Rewrite RequestState as a discriminated union on status with variants 'loading', 'success', and 'error', then update the checks in handle to narrow on state.status. Once the type is right, the @ts-expect-error directive errors as unused — remove it.
-
Type query at line 14 must resolve to a type containing
User
Exercise: discriminated union or plain shape
Section titled “Exercise: discriminated union or plain shape”A discriminated union earns its keep only when a value really has several distinct, mutually exclusive shapes. A value with one shape is just an object. Sort each scenario by whether the pattern fits.
Some values genuinely have multiple distinct shapes — those want a discriminated union. Others are just one shape, or a single value. Sort each scenario into the bucket that fits. Drag each item into the bucket it belongs to, then press Check.
onClick or href, never bothExternal resources
Section titled “External resources”The authoritative reference, including the compiler's narrowing rules for the discriminant form.
Matt Pocock's treatment of the pattern, including the canonical anti-patterns and the reflex for spotting them.
The wire-boundary application — parsing an unknown JSON payload into a discriminated union. Lands properly in the validation unit later in the course.
The canonical naming of the pattern this lesson is built on, with the alert-component example that motivated it in the React world.