Discriminated unions: the TypeScript feature I'd teach first

Three booleans for one request gave us a spinner on top of an error message. One union with a status field fixed it, and now the compiler tells me when I forget a case.

Last week I reviewed a pull request that fixed a loading spinner showing on top of an error message. The fix was one line, a setLoading(false) in a catch block that didn't have it. Correct, and I approved it, but the bug was never about the missing line.

The hook looked like most hooks that fetch something:

ts
// src/hooks/useUser.ts, the before
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [user, setUser] = useState<User | null>(null);

Three variables, eight combinations, and about half of them mean nothing. Loading with a user? Error with a user? Loading and error at the same time, which is exactly what we shipped? Every one of those can be written, so every one of them will be, eventually.

One field that says what's going on

Let's put the three into one value that can only be in one state at a time.

ts
// src/hooks/useUser.ts, the after
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

const [state, setState] = useState<RequestState<User>>({ status: 'idle' });

This is a discriminated union: a union of object types that share one field, here status, with a different literal value in each member. The literal is the discriminant, and TypeScript narrows on it.

tsx
// src/components/UserCard.tsx
function UserCard({ state }: { state: RequestState<User> }) {
  switch (state.status) {
    case 'idle':
      return null;
    case 'loading':
      return <Spinner />;
    case 'error':
      // state is { status: 'error'; error: Error } in here, nothing else
      return <ErrorMessage error={state.error} />;
    case 'success':
      return <Profile user={state.data} />;
  }
}

Inside each case the type is narrowed to that one member. state.data doesn't exist on the error branch and the compiler says so if you try. The spinner and the error message can't both render, because there is no state in which both conditions are true.

The idea has a name, making impossible states impossible, and it comes from a talk about Elm that I'll link at the end. It applies to any language with unions, and TypeScript happens to have good ones.

Setting the state

The setter side gets simpler too, because you replace the whole value instead of juggling three.

ts
// src/hooks/useUser.ts
useEffect(() => {
  let cancelled = false;
  setState({ status: 'loading' });
  fetchUser(id)
    .then((data) => {
      if (!cancelled) setState({ status: 'success', data });
    })
    .catch((error) => {
      if (!cancelled) setState({ status: 'error', error });
    });
  return () => {
    cancelled = true;
  };
}, [id]);

There is no way to forget to clear the error, because { status: 'loading' } has no error in it.

The compiler as a checklist

Add a fifth state next year, say 'stale' for cached data being refreshed, and the switch above compiles without a complaint. It just returns undefined for the new case. I want a complaint. The trick is a function that only accepts never:

ts
// src/utils/assertNever.ts
export function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
ts
switch (state.status) {
  // the four cases from above, then
  default:
    return assertNever(state);
}

When every case is handled, state in the default branch has been narrowed to never, so the call type checks. Add a member to the union and state in default is the new member, which is not never, and you get a red line right where the missing case belongs. The compiler is now my checklist for new states.

Where else it shows up

  • Reducers: an Action union with type as the discriminant is the same thing, useReducer was built for it.
  • API responses: { ok: true; data: T } | { ok: false; error: string }. A boolean works as a discriminant too, it's the literals true and false doing the job.
  • Form fields, tabs, websocket messages: anything you'd describe with "it's either this or that".

One more thing that arrived two weeks ago: TypeScript 4.6 narrows through destructuring. When every member has the field, const { type, payload } = action used to lose the link between the two, and if (type === 'add') left payload as the full union. Now it narrows payload as well. It was the one place the pattern felt clunky, and it's gone.

So if I'm teaching someone TypeScript, this is where I start. Generics and mapped types can wait.

Read more here: