Community Article

Discriminated Unions: The Best TypeScript Pattern

The strong-stance defence of discriminated unions: why distinct states should be a literal-tagged union of object types, the exhaustive-switch with never check, and shapes I reach for every week.

Discriminated Unions: The Best TypeScript Pattern

The strong-stance defence of discriminated unions: why distinct states should be a literal-tagged union of object types, the exhaustive-switch with never check, and shapes I reach for every week.

ts-union-intersection
ts-type-narrowing
ts-type-guards
type-system
interview-prep
norapetrov

By @norapetrov

March 3, 2026

·

Updated May 20, 2026

299 views

3

4.4 (11)

If I had to pick one TypeScript pattern I think every codebase should adopt, it is discriminated unions. Not generics, not utility types, not template literal types. Discriminated unions. They model state more honestly than every alternative I have tried, they make exhaustiveness checks free, and they catch a class of bugs that other type-modelling techniques silently allow. The opinion is not subtle: this is the best pattern in the language, and I have not changed my mind in five years of writing TypeScript daily.

The argument I want to defend in this piece is that any time your application has a value that can be in one of several distinct states, the type should reflect that distinctness explicitly with a discriminant. The shape "loading | success | error" is not three optional fields on one type; it is a union of three distinct types, each with its own required fields, joined by a literal-string tag. Once that shape is established, the compiler's narrowing does the rest of the work.

What a discriminated union actually is

A discriminated union is a union type where every member has a literal-typed property in common, called the discriminant. The discriminant's value is different for each member, so checking it tells the compiler which variant you have.

type RequestState =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: User[] }
    | { status: 'error'; error: Error };

status is the discriminant. Its type in each branch is a literal string ('idle', 'loading', etc.), not just string. Because the literals are distinct, a check like if (state.status === 'success') narrows the type to the success variant. Inside the if, state.data is accessible; inside the else, it is not.

The naming of the discriminant is not magic. kind, type, tag, variant, _ (underscore in some teams' style) are all common conventions. The mechanic is the same: a literal-typed shared property, distinct values per variant.

Why this beats the optional-fields shape

The shape that every junior reaches for first is the everything-optional record:

// Don't do this
interface RequestState {
    isLoading: boolean;
    data?: User[];
    error?: Error;
}

The runtime values your code actually produces fall into a small set of valid combinations: { isLoading: true, data: undefined, error: undefined }, { isLoading: false, data: [...], error: undefined }, { isLoading: false, data: undefined, error: ... }. The type allows the entire Cartesian product of the fields, including invalid combinations like { isLoading: true, data: [...], error: ... } (loading and finished and errored simultaneously?).

Code consuming the type now has to check data and error and isLoading to know which case it is in, and the compiler cannot help with exhaustiveness because the type does not encode the cases. Add a fourth state ("validating" or "retrying") and every consumer must be updated, but the compiler will not point you at the consumers; you have to find them yourself.

The discriminated union version makes the invalid combinations unrepresentable. There is no way to hold "loading and success at the same time" because no variant of the union has both. Adding a new variant produces compile errors at every consumer that did not handle it (with the right exhaustiveness check, see below). Optional fields encode "any of these might be missing"; discriminated unions encode "exactly one of these cases is true".

The exhaustive-switch pattern

The pattern that makes the union worth its weight is the exhaustive switch with a never check.

function render(state: RequestState): string {
    switch (state.status) {
        case 'idle':
            return 'Click to load';
        case 'loading':
            return 'Loading...';
        case 'success':
            return `Loaded ${state.data.length} users`;
        case 'error':
            return `Error: ${state.error.message}`;
        default:
            const _exhaustive: never = state;
            throw new Error(`Unhandled status: ${(state as any).status}`);
    }
}

The default branch declares a never variable and assigns state to it. If the switch handled every variant, state is narrowed to never in the default, and the assignment compiles. If a new variant is added to the union and this switch is not updated, state is narrowed to that unhandled variant, which is not assignable to never, and the compiler errors at the assignment line.

This is the entire reason discriminated unions are the best pattern in the language. Adding a state to the system causes compile errors at every consumer that needs to be updated. The compiler does not just allow your existing code to keep compiling against the new shape; it points at every place that needs to handle the new case. That feedback loop is what other type-modelling approaches lack.

Real shapes from real codebases

Three discriminated unions I have written in production, all with the same skeleton.

Network state.

type FetchState<T> =
    | { kind: 'idle' }
    | { kind: 'loading' }
    | { kind: 'success'; value: T }
    | { kind: 'error'; error: Error };

The same shape works for any async resource: user fetch, paginated list, file upload progress (with an extra kind: 'progress'; pct: number). The component reads the kind and renders the matching UI; the reducer handles the transitions between kinds.

Authentication state.

type AuthState =
    | { kind: 'unauthenticated' }
    | { kind: 'authenticating' }
    | { kind: 'authenticated'; user: User; token: string }
    | { kind: 'token-expired'; user: User };

The 'token-expired' variant carries the user but not the token, because the consumer should not be able to use the expired token by accident. The type makes the misuse impossible, not just discouraged.

The whole point of the pattern shows up in the consumer code, not the type definition. The component that renders the auth state and the reducer that transitions between states both narrow on the discriminant, and the compiler enforces every case.

function renderAuth(state: AuthState): JSX.Element {
    switch (state.kind) {
        case 'unauthenticated':
            return <SignInButton />;
        case 'authenticating':
            return <Spinner label="Signing you in" />;
        case 'authenticated':
            return <Dashboard user={state.user} token={state.token} />;
        case 'token-expired':
            return <RefreshPrompt user={state.user} />;
        default:
            const _exhaustive: never = state;
            throw new Error(`Unhandled auth state: ${(state as any).kind}`);
    }
}

Notice that state.token is reachable only inside the 'authenticated' case; trying to use it in the 'token-expired' branch is a compile error, because the variant does not have that field. Adding a fifth state ('locked', 'mfa-required') breaks the switch at the never line and the compiler points at this function as one of the places that needs updating. The reducer that produces these states uses the same shape: every action returns a fully-formed variant of AuthState, never an in-between with some fields filled and others undefined.

Form field state.

type FieldState<T> =
    | { kind: 'pristine'; value: T }
    | { kind: 'dirty'; value: T; serverValue: T }
    | { kind: 'invalid'; value: T; error: string };

The 'dirty' variant carries both the current and the server value, so the component can show a "you have unsaved changes" indicator with the original value visible on hover. The 'invalid' variant carries the error message. The pristine variant cannot have an error or a server value, by construction.

Common discriminant choices and naming

I have seen four conventions in the wild and they are essentially interchangeable.

Discriminant nameWhere I see it
kindOlder TS codebases, generally functional-style code
typeRedux actions, React reducers
tagFunctional-programming-influenced codebases (fp-ts, neverthrow)
_kind or _Teams that want to make the discriminant visually distinct

The team should pick one and stick with it. Mixing kind and type in the same codebase is fine but unfortunate; consistency makes the pattern easier to spot at a glance. I default to kind because type collides visually with the TypeScript type alias keyword, which trips up newcomers reading the code aloud.

Beyond strings: numeric and boolean discriminants

The discriminant does not have to be a string. Numbers and booleans work too, as long as the literal types are distinct.

type Result<T, E> =
    | { ok: true; value: T }
    | { ok: false; error: E };

The boolean ok is a perfectly valid discriminant. Two variants, two literal values, narrowing works. This is the shape I reach for when the binary state is the natural model (success vs failure, present vs absent, valid vs invalid). For three or more states, a string is more readable; for two, a boolean is sometimes cleaner.

Numeric literals work the same way, and they shine when the natural identifier is already a number. HTTP response handling is the cleanest example I have shipped:

type HttpResponse<T> =
    | { status: 200; body: T }
    | { status: 201; body: T; location: string }
    | { status: 204 }
    | { status: 400; errors: string[] }
    | { status: 401 }
    | { status: 500; traceId: string };

The key detail is that status is 200 | 201 | 204 | 400 | 401 | 500, a union of literal numbers, not number. If you write status: number, narrowing falls apart: if (res.status === 200) no longer narrows, because 200 is just one of infinitely many number values and the compiler cannot tell that 200 excludes the other variants. The literal types are what make the discriminant work; the moment you widen to string or number, the pattern collapses to a regular guarded property check with no exhaustiveness story.

Where I have seen this pattern misused

Three anti-patterns I have caught in code review more than once.

Widening the discriminant to string or number. Someone writes interface Event { type: string; payload: unknown } and calls it a discriminated union. It is not. With type: string, if (e.type === 'click') does not narrow payload to anything more specific, because the type system has no way to associate the string 'click' with a particular payload shape. The fix is to make type a union of literals (type: 'click' | 'submit' | 'navigate') and split the variants accordingly.

Reusing the same discriminant value across variants. I once saw a union with two variants both tagged kind: 'pending', distinguished by the presence or absence of a field. Narrowing on kind === 'pending' then gave back the union of both variants, which defeated the point. Each variant's discriminant value must be unique within the union; if two cases share a tag, they should be one variant with optional fields, or the tag should be split.

Burying the discriminant in a nested object. A type like { meta: { kind: 'success' }, data: T } works mechanically (you can narrow state.meta.kind), but the narrowing only applies to state.meta, not to state itself. Modern TypeScript (>= 4.4) does narrow some nested cases when you destructure the inner object first or read the discriminant into a local, but the narrowing is more fragile than top-level: it breaks across function boundaries, across optional chaining, and when the inner object is reassigned. Keep the discriminant at the top of the variant whenever you can; reach for nested discriminants only when an external schema forces the shape.

Where the pattern strains

Two cases I have hit where discriminated unions are awkward.

Many shared fields across variants. If every variant has the same ten fields and only one or two differ, you end up restating the shared fields in every variant. The fix is to factor the shared part into a base type and intersect each variant with it.

interface Base {
    id: string;
    createdAt: Date;
    updatedAt: Date;
}
type Order =
    | (Base & { status: 'draft' })
    | (Base & { status: 'submitted'; submittedAt: Date })
    | (Base & { status: 'fulfilled'; fulfilledAt: Date; tracking: string });

The intersection works, but the type is harder to read at a glance. For three or four shared fields, just repeat them in each variant; the redundancy is more honest than the cleverness.

Discriminants in nested objects. Covered in more detail in the misuse section above. The short version: narrowing through a nested discriminant is more fragile than narrowing on a top-level one, even though TypeScript >= 4.4 handles many nested cases. Destructure the inner object first, or keep the discriminant at the top level whenever you control the schema.

The pattern condensed

The recipe, in five lines:

  • Every distinct application state is a separate object type with its own required fields.
  • Every variant has a shared literal-typed property, the discriminant, with a unique value per variant.
  • The full state type is the union of the variants.
  • Consumers narrow on the discriminant with if, switch, or pattern-matching utilities.
  • Exhaustiveness is enforced with a never check in the default branch of the switch.

Five rules, no fancy machinery, supported by the language since 2.0. Every codebase I have introduced this pattern to has reported the same outcome: fewer "this state should be impossible" bugs in production, faster onboarding for juniors who can read the type and immediately see the state machine, and more reliable refactors because adding a state breaks the consumers that need updating.

I will keep advocating for this

The pattern is built into the language; nothing third-party is required. The shape it enforces matches how state actually behaves (one case at a time, not a soup of optional fields). The compiler's exhaustiveness check pays for the small extra ceremony of writing a discriminant.

Every time a coworker asks me, "should this be optional fields or a union?", my answer is the same: it should be a union. If you try the pattern on the next state-modelling problem you write, then look at the resulting code and consumers, and decide for yourself whether the unconditional advocacy is justified, that is the only test that matters. My prediction is that you will write fewer "is loading and have data simultaneously?" defensive checks afterwards, and that prediction has not yet been wrong on any codebase I have seen.

Back to Articles