Ch. 2 · TypeScript

TypeScript Discriminated Unions for UI State

Replace isLoading, error and data flags with one status union so impossible UI states stop compiling and each branch sees only its own fields.

~6 min readbeginnerupdated Oct 7, 2026

Almost every screen that loads data has the same four moments: nothing has happened yet, a request is in flight, data arrived, or something failed. How you type those moments decides how many bugs the compiler can find for you. A discriminated union is the standard answer, and it’s one of the most common TypeScript questions in front-end interviews because it shows whether you model state or just annotate it.

The flag soup most components start with

Here’s the shape you’ll find in plenty of real code:

type User = { id: number; name: string };

type LooseState = {
  isLoading: boolean;
  error?: string;
  data?: User[];
};

const nonsense: LooseState = { isLoading: true, error: "timeout", data: [] }; // compiles
TypeScript

The type allows eight combinations of “loading or not, error or not, data or not”, and most of them mean nothing. Is the component loading, or did it fail? Should it show the spinner, the error banner, or the empty list? Every render has to answer that with a chain of if statements, and every author answers it slightly differently. When someone forgets to clear error after a successful retry, the screen shows a stale error over fresh data, and the type system is fine with it.

The deeper problem is that data is optional everywhere, so every use needs state.data?.map(...) or a non-null assertion, even in code that only runs after a successful load. The types don’t know about the relationship between the flags and the payload.

One status field, four shapes

A discriminated union lists the states explicitly. Each member has a common property with a different literal type (the discriminant) and only the fields that make sense in that state:

type UsersState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string; retryCount: number };
TypeScript

Now there are exactly four possibilities. data exists only on success, and on success it’s required, not optional. retryCount exists only on error. The nonsense combinations can’t be written:

const bad: UsersState = { status: "loading", data: [] };
const bad2: UsersState = { status: "success" };
TypeScript
du1.ts(33,46): error TS2353: Object literal may only specify known properties, and 'data' does not exist in type '{ status: "loading"; }'.
du1.ts(34,7): error TS2322: Type '{ status: "success"; }' is not assignable to type 'UsersState'.
  Property 'data' is missing in type '{ status: "success"; }' but required in type '{ status: "success"; data: User[]; }'.
Text

Notice how specific those messages are. TypeScript used the status value to work out which member you meant, then checked the rest of the object against that member alone.

Narrowing in the render function

Checking the discriminant narrows the whole object:

function render(state: UsersState): string {
  switch (state.status) {
    case "idle":
      return "Press load";
    case "loading":
      return "Loading...";
    case "success":
      return state.data.map((u) => u.name).join(", ");
    case "error":
      return `Failed: ${state.error} (attempt ${state.retryCount})`;
  }
}
TypeScript

Inside case "success", state has type { status: "success"; data: User[] }, so state.data needs no ?. and no !. Inside case "error", you get error and retryCount. Outside any check you get nothing beyond status:

function broken(state: UsersState) {
  return state.data.length;
}
TypeScript
du1.ts(30,16): error TS2339: Property 'data' does not exist on type 'UsersState'.
  Property 'data' does not exist on type '{ status: "idle"; }'.
Text

That’s the behaviour you want: the compiler makes you decide which state you’re in before you touch state-specific data. The same narrowing works with if (state.status === "success"), with early returns, and with ternaries in JSX.

There’s a nice side effect for the switch. Because every member is handled and each case returns, TypeScript accepts the string return type without a trailing return. Add a fifth state, say { status: "cancelled" }, and the function stops compiling, because it can now fall off the end and return undefined:

du3.ts(2,28): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
Text

That error points at the signature rather than the missing case, so it’s less helpful in a long file, and it disappears entirely if the function returns void or has no annotated return type. For a stronger guarantee there’s the never check covered in the exhaustiveness note.

Moving between states

Unions pay off again when you write the transitions. A reducer that returns a full new state for each action can’t leave stale fields behind, because each return is a complete member:

type Action =
  | { type: "fetch" }
  | { type: "resolved"; users: User[] }
  | { type: "rejected"; message: string };

function reducer(state: UsersState, action: Action): UsersState {
  switch (action.type) {
    case "fetch":
      return { status: "loading" };
    case "resolved":
      return { status: "success", data: action.users };
    case "rejected": {
      const retryCount = state.status === "error" ? state.retryCount + 1 : 1;
      return { status: "error", error: action.message, retryCount };
    }
  }
}
TypeScript

The action type is itself a discriminated union, so action.users is only available in the "resolved" case. And reading state.retryCount requires checking state.status === "error" first, which is exactly the logic you’d want anyway. Compare that with the flag version, where a successful fetch had to remember to set error: undefined.

Where narrowing quietly stops working

Two situations come up again and again.

The discriminant widens to string. Build a state object without a type annotation and pass it along later:

const next = { status: "success", data: [] as User[] };
render(next);
TypeScript
du2.ts(10,8): error TS2345: Argument of type '{ status: string; data: User[]; }' is not assignable to parameter of type 'UsersState'.
  Type '{ status: string; data: User[]; }' is not assignable to type '{ status: "success"; data: User[]; }'.
    Types of property 'status' are incompatible.
      Type 'string' is not assignable to type '"success"'.
Text

Object properties are mutable, so TypeScript infers status: string, not "success". Fix it by annotating the variable (const next: UsersState = ...), returning the object directly from a function typed UsersState, or using as const. The annotation is usually the clearest.

The discriminant is copied into a let. Since TypeScript 4.6, destructuring with const keeps the link: const { status } = state; if (status === "success") state.data compiles. Change const to let and the link is gone, because the compiler can’t assume the variable still matches the object:

du1.ts(47,18): error TS2339: Property 'data' does not exist on type 'UsersState'.
  Property 'data' does not exist on type '{ status: "idle"; }'.
Text

A third, softer mistake is choosing a discriminant that isn’t a literal. A kind: string field or a boolean shared by two members with the same value can’t distinguish them. Literal strings are the norm because they read well in logs and debuggers too.

Interview practice

Product wants the user list to stay visible while a refresh is in progress, with a small spinner in the corner. Your union has separate "loading" and "success" states, so a refresh currently wipes the list. How would you change the type?

Answer

The requirement adds a real state: “I have data, and I’m also fetching”. I wouldn’t express it by adding an optional data? to "loading", because that brings back the ambiguity we removed. Two clean options exist.

One is a new member, { status: "refreshing"; data: User[] }. The render function gets an explicit case that shows the list plus the spinner, and the reducer moves from "success" to "refreshing" on fetch, keeping state.data. The other is to keep status for the request lifecycle and add isRefreshing: boolean only to the "success" member. I’d lean towards the first because every visual state then has its own case and the compiler will force every switch in the codebase to handle it. Either way, the reducer’s "fetch" case now needs to look at the current state: if it’s "success", go to "refreshing" with the same data; otherwise go to "loading". I’d also decide what happens if the refresh fails, probably an error state that still carries the last good data, and type that explicitly rather than leaving it to chance.

Further reading

More in TypeScript

esc