TypeScript Discriminated Unions in 2026: Make Impossible UI States Unrepresentable

Vector illustration of exclusive UI state branches splitting from one status tag, representing TypeScript discriminated unions

Vector illustration of exclusive UI state branches splitting from one status tag, representing TypeScript discriminated unions

I used to ship UI state like this:

type BadUiState = {
  loading: boolean;
  error: string | null;
  data: Item[] | null;
};

It looks harmless. It compiles. Product managers approve the mock. Then a real user hits a screen that is somehow loading, showing yesterday's list, and flashing an error toast at the same time.

That is not a React bug. That is a type design bug.

Three independent fields create eight combinations. Most of them are nonsense. TypeScript will happily let you render every nonsense combination unless you stop modeling state as a bag of booleans.

Discriminated unions are how I stop that in 2026. One tag. One payload shape per tag. Impossible states become unrepresentable — not "hopefully avoided in a code review," but rejected by tsc before the PR lands.

This guide is the practical playbook: what a discriminated union is, how to model async UI, how narrowing and exhaustiveness checks work, how satisfies and never keep you honest, and the traps that make teams abandon the pattern too early.

What a discriminated union actually is

A union type is "this value is one of these shapes." A *discriminated* union adds a shared literal field — the discriminant — so TypeScript can tell the shapes apart.

type Idle = { status: "idle" };
type Loading = { status: "loading" };
type Success = { status: "success"; data: Item[] };
type Failure = { status: "error"; error: string };

type UiState = Idle | Loading | Success | Failure;

status is the discriminant. Every member has it. Each member uses a different string literal. Once you check state.status === "success", TypeScript narrows state to Success and you get data for free. On "error", you get error. On "loading", there is no data field to accidentally read.

That last part is the whole point. You cannot write state.data without narrowing. The type system deletes the footgun.

Compare that to the boolean soup version: data can be non-null while loading is true and error is a string. The type says that is fine. Your UI says that is a bug.

Why this matters more for UI in 2026

Frontend state got denser. Streaming responses, optimistic updates, server components with client islands, multi-step checkout, and "save as draft / publish / schedule" flows all multiply the number of real states a screen can be in.

If you model those with independent flags, bugs show up as flicker, double toasts, empty tables during "success," and spinners that never die. Users do not file tickets titled "non-exclusive boolean state." They say the app feels broken.

Discriminated unions push the hard thinking to type design time:

  1. List the states that are actually possible.
  2. Give each state only the fields it needs.
  3. Force every render and reducer path to handle every state.

You still need good runtime logic. Types do not fetch data for you. They just stop you from describing a world where loading and error and success are all true.

Infographic comparing boolean soup UI state with a clean discriminated union of idle loading success and error
Left: independent loading, error, and data flags create impossible combinations. Right: one status tag, one payload shape per state.

The async fetch state machine I actually ship

Here is the pattern I use for a list page, a detail drawer, or any "load something when the query changes" view.

type Item = { id: string; title: string };

type FetchState =
  | { status: "idle" }
  | { status: "loading"; previous?: Item[] }
  | { status: "success"; data: Item[] }
  | { status: "error"; error: string; previous?: Item[] };

async function loadItems(
  query: string,
  previous: Item[] | undefined,
  signal: AbortSignal
): Promise<FetchState> {
  try {
    const res = await fetch(`/api/items?q=${encodeURIComponent(query)}`, {
      signal,
      headers: { Accept: "application/json" },
    });

    if (!res.ok) {
      return {
        status: "error",
        error: `Request failed (${res.status})`,
        previous,
      };
    }

    const data = (await res.json()) as Item[];
    return { status: "success", data };
  } catch (err) {
    if (err instanceof DOMException && err.name === "AbortError") {
      // Caller already moved on; do not invent a failure UI.
      return { status: "idle" };
    }
    return {
      status: "error",
      error: err instanceof Error ? err.message : "Unknown error",
      previous,
    };
  }
}

Notice previous lives only on loading and error. That is intentional. Soft refresh can keep the old list visible under a subtle loading affordance. Hard first load has no previous data, so the loading branch renders a skeleton. Success never carries an error string. Error never pretends to be success with empty data.

That is make-impossible-states-unrepresentable in one screenshot of a type.

Narrowing with switch (and why if-chains get messy)

You can narrow with if, but switch on the discriminant scales better and pairs cleanly with exhaustiveness checks.

function renderList(state: FetchState) {
  switch (state.status) {
    case "idle":
      return "Type to search.";
    case "loading":
      return state.previous?.length
        ? `Updating… showing ${state.previous.length} cached items`
        : "Loading…";
    case "success":
      return state.data.length
        ? state.data.map((item) => item.title).join(", ")
        : "No items matched.";
    case "error":
      return `Something broke: ${state.error}`;
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}

The default branch with never is the seatbelt. If a teammate adds { status: "stale"; ... } to FetchState and forgets to update renderList, TypeScript errors on the assignment to never. That is the exhaustiveness check people mean when they say "the compiler keeps the switch honest."

Without it, a new status silently falls through and you ship a blank panel.

Infographic of a switch on status with a never exhaustiveness check catching a missing union member
Switch on the discriminant, then assign the leftover state to never so new variants fail at compile time.

Exhaustiveness via never in reducers too

The same trick belongs in reducers and event handlers.

type Event =
  | { type: "QUERY_CHANGED"; query: string }
  | { type: "LOADED"; data: Item[] }
  | { type: "FAILED"; error: string }
  | { type: "RESET" };

function reduce(state: FetchState, event: Event): FetchState {
  switch (event.type) {
    case "QUERY_CHANGED":
      return {
        status: "loading",
        previous: state.status === "success" ? state.data : undefined,
      };
    case "LOADED":
      return { status: "success", data: event.data };
    case "FAILED":
      return {
        status: "error",
        error: event.error,
        previous: state.status === "success" ? state.data : undefined,
      };
    case "RESET":
      return { status: "idle" };
    default: {
      const _exhaustive: never = event;
      return _exhaustive;
    }
  }
}

Events are a second discriminated union. State is the first. Together they form a tiny typed state machine without pulling in a library. For many screens, that is enough.

Infographic of a multi-step wizard modeled as tagged union steps with only legal fields on each step
Each wizard step is a variant with only the fields that step is allowed to hold — illegal jumps become type errors.

Forms, modals, and wizards: where unions shine

Boolean soup is especially painful in multi-step UI.

// Painful
type BadWizard = {
  step: number;
  email?: string;
  planId?: string;
  paymentMethodId?: string;
  submitting: boolean;
  submitError?: string;
};

step is a number, so step 1 can somehow have a paymentMethodId. Submitting can be true on the email step. The type will not stop you.

A union per step will:

type Wizard =
  | { step: "email"; email: string }
  | { step: "plan"; email: string; planId: string }
  | {
      step: "pay";
      email: string;
      planId: string;
      paymentMethodId: string;
    }
  | {
      step: "submitting";
      email: string;
      planId: string;
      paymentMethodId: string;
    }
  | {
      step: "done";
      email: string;
      planId: string;
      receiptId: string;
    }
  | {
      step: "failed";
      email: string;
      planId: string;
      paymentMethodId: string;
      error: string;
    };

Now "go to pay" must carry planId. "done" must carry receiptId. "failed" must carry error. Your transition functions become the API of the wizard, and illegal jumps become type errors.

I use this for checkout, onboarding, and any modal that is secretly a state machine with a pretty face.

Discriminants that are not strings

String literals are the common case. Numbers and booleans can work, but booleans are a trap when you only have two states that later grow. Prefer string tags once a feature might gain a third mode.

Symbols are rarely worth it for UI state. Stick to string literals your team can read in Redux DevTools, logs, and React Query style dumps.

You can also discriminate on a shared field that is already domain data:

type Result =
  | { ok: true; value: Item[] }
  | { ok: false; error: string };

ok is a boolean discriminant. Fine for Result types. For screens with four or five modes, I still prefer status: "…".

satisfies keeps config objects aligned without widening

In 2026, satisfies is part of how I keep maps of handlers typed without losing literal inference.

const handlers = {
  idle: () => "Start a search",
  loading: (s: Extract<FetchState, { status: "loading" }>) =>
    s.previous ? "Refreshing…" : "Loading…",
  success: (s: Extract<FetchState, { status: "success" }>) =>
    `${s.data.length} items`,
  error: (s: Extract<FetchState, { status: "error" }>) => s.error,
} satisfies {
  [K in FetchState["status"]]: (
    state: Extract<FetchState, { status: K }>
  ) => string;
};

If you add a status to FetchState and forget a handler key, satisfies fails. If a handler's parameter shape drifts, it fails. You get a keyed exhaustiveness check without a giant switch — useful for component maps and route tables.

Pattern matching libraries vs native switch

Libraries like ts-pattern are nice when nested matching gets deep. For most UI state, native switch plus never is enough, zero dependency, and easy for juniors to read in review.

My rule: if a single discriminant switch is hard to follow, the state model is probably too wide. Split the union. Nested matching often means you stuffed two machines into one type.

React wiring without fighting the type checker

A minimal React sketch:

import { useEffect, useState } from "react";

export function ItemSearch({ query }: { query: string }) {
  const [state, setState] = useState<FetchState>({ status: "idle" });

  useEffect(() => {
    if (!query) {
      setState({ status: "idle" });
      return;
    }

    const controller = new AbortController();
    const previous =
      state.status === "success" ? state.data : undefined;

    setState({ status: "loading", previous });

    loadItems(query, previous, controller.signal).then((next) => {
      if (!controller.signal.aborted) {
        setState(next);
      }
    });

    return () => controller.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: only re-run on query
  }, [query]);

  switch (state.status) {
    case "idle":
      return <p>Search for items.</p>;
    case "loading":
      return (
        <div>
          <p>Loading…</p>
          {state.previous ? <ItemList items={state.previous} dimmed /> : null}
        </div>
      );
    case "success":
      return <ItemList items={state.data} />;
    case "error":
      return (
        <div role="alert">
          <p>{state.error}</p>
          {state.previous ? <ItemList items={state.previous} dimmed /> : null}
        </div>
      );
    default: {
      const _exhaustive: never = state;
      return _exhaustive;
    }
  }
}

The render switch cannot forget a branch. The loading UI can use previous only because that field exists on that member. AbortController still cancels the fetch; the union still models the screen.

Common mistakes that make unions feel "too heavy"

1. Union-washing every prop. Not every boolean needs a tagged union. isOpen on a presentational modal is fine. Use discriminated unions where combinations explode or where fields only exist in some modes.

2. Optional fields on every member. If data?: Item[] appears on all variants, you reintroduced boolean soup inside a costume. Put data only on success (and maybe previous on loading/error if you truly need soft refresh).

3. Stringly status without literals. status: string destroys discrimination. Use "loading" as const or type the field as the literal union.

4. Narrowing with truthiness instead of the tag. Prefer state.status === "success" over if (state.data). Truthiness checks do not teach TypeScript which variant you are in as reliably as the discriminant.

5. Forgetting serialization boundaries. JSON from an API is unknown until you validate. A Zod schema (or similar) that outputs your discriminated union is how runtime reality meets compile-time hope. Types alone do not parse network payloads.

Zod (or any parser) as the bridge from API to union

import { z } from "zod";

const ItemSchema = z.object({
  id: z.string(),
  title: z.string(),
});

const ApiSuccessSchema = z.object({
  status: z.literal("success"),
  data: z.array(ItemSchema),
});

const ApiErrorSchema = z.object({
  status: z.literal("error"),
  error: z.string(),
});

const ApiStateSchema = z.discriminatedUnion("status", [
  ApiSuccessSchema,
  ApiErrorSchema,
]);

type ApiState = z.infer<typeof ApiStateSchema>;

z.discriminatedUnion mirrors the TypeScript idea at runtime. Invalid payloads fail loudly at the boundary instead of poisoning your UI state machine.

When a library state machine is worth it

XState and friends earn their keep for long-lived, multi-actor flows with parallel regions, delayed transitions, and visualizer needs. For a search page, a settings form, or a three-step onboarding, a discriminated union plus a reducer is usually clearer and easier to delete later.

Start with the union. Graduate to a library when the transition table no longer fits in your head or your PR description.

A quick refactor checklist

When you smell boolean soup:

  1. Write down every UI screenshot that should be possible.
  2. Name each screenshot as a status literal.
  3. Attach only the data that screenshot needs.
  4. Replace flag updates with events that return a whole new state object.
  5. Render with switch + never.
  6. Parse API input into the union at the boundary.
  7. Delete the old booleans in the same PR so nobody keeps writing to them.

Do that on one painful screen. The next screen goes faster because the team has a pattern to copy.

Performance and bundle size: not the blocker

Discriminated unions are a type-level tool. They erase at compile time. Your bundle does not grow because you modeled state correctly. Runtime cost is the same object shapes you would have built anyway — usually smaller, because you stop storing contradictory fields.

What *can* cost you is over-normalized deep cloning in reducers. Return new objects only for the branches that change. That is normal React advice, not a union tax.

How this pairs with the rest of a 2026 TypeScript stack

  • React Server Components: keep server props narrow; use unions on the client islands that own interactive state.
  • Next.js app router: search params and route state still deserve typed unions when they drive exclusive UI modes.
  • tRPC / typed REST: generate or share the success/error union so client and server agree on the discriminant.
  • Testing: assert on status first. Snapshotting a whole soup object hides illegal combinations. Testing a union branch is obvious.

The mindset shift

Stop asking "what flags do I need?" Start asking "what situations can this UI actually be in?"

Flags describe knobs. Unions describe moments. Users experience moments. Types should too.

When a designer adds a "partial success with warnings" mock, that is a new variant — not a new boolean piled onto success. Name it. Give it fields. Update the switch. Let never find the call sites you missed.

That is the whole craft: make illegal screens untypable, then sleep better.

Wrap-up

TypeScript discriminated unions are not academic. They are how you stop shipping loading-plus-error-plus-stale-data screens in 2026.

Model exclusive UI modes as tagged variants. Narrow on the tag. Prove exhaustiveness with never. Parse at the boundary. Keep optional fields from leaking into every member. Use satisfies when you want keyed handler maps. Reach for heavier state machines only when the flow outgrows a clean union.

If your types can represent a nonsense screen, eventually a user will see that screen. Delete the nonsense from the type, and a whole class of bugs loses its oxygen.

Next time you reach for loading, error, and data as siblings, pause. Write the union instead. Your future self — and your QA board — will notice.