DEV Community

Kai Thorne
Kai Thorne

Posted on

TypeScript Discriminated Unions: Making Impossible States Impossible

TypeScript Discriminated Unions: Making Impossible States Impossible

You have a data, an error, and an isLoading field on a type. You know that only one or two should be set at a time. But TypeScript doesn't know that. So every time you access those fields, you're one null-check away from a runtime crash.

The fix is a discriminated union — and it's one of the most impactful patterns TypeScript offers.

The Problem: States That Shouldn't Exist

Here's a type I see all the time:

type APIResponse = {
  data: User[] | null;
  error: string | null;
  isLoading: boolean;
};
Enter fullscreen mode Exit fullscreen mode

At first glance this looks fine. But what does { data: [user1], error: "Something broke", isLoading: true } mean? Is it loading, successful, or errored?

The type says "yes" to all three — that's an impossible state. And your code has to constantly guess:

function render(response: APIResponse) {
  if (response.isLoading) {
    return <Spinner />;
  }
  // Did we check enough here? What if response.data is null?
  if (response.error) {
    return <Error message={response.error} />;
  }
  // TypeScript thinks this could still be null
  return <List items={response.data} />; // ⚠️ possible null
}
Enter fullscreen mode Exit fullscreen mode

This code has a bug. When isLoading is false and error is null, data could still be null. You're forced to add defensive checks everywhere. And those checks don't compose — error being null doesn't tell you anything about data.

You end up with shotgun null checks scattered across every function that touches this type. One wrong refactor and the runtime crashes.

The Fix: A Discriminated Union

Instead of one type with nullable fields, define each state as a separate variant with a literal status field as the discriminant:

type APIResponse =
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string };
Enter fullscreen mode Exit fullscreen mode

Now impossible states aren't representable. You can't create a variant with both data and error. You can't have isLoading: true alongside data from a previous request.

Narrowing works naturally:

function render(response: APIResponse) {
  switch (response.status) {
    case "loading":
      return <Spinner />;
    case "success":
      return <List items={response.data} />; //  data is available
    case "error":
      return <Error message={response.error} />; //  error is available
  }
}
Enter fullscreen mode Exit fullscreen mode

Inside each case branch, TypeScript knows exactly which fields exist. data is only accessible in the "success" branch. error is only accessible in the "error" branch. If you try to access response.data before narrowing, TypeScript gives you a compile error.

The safety is enforced at the type level — not at runtime, and not through discipline.

Pattern 1: API Response Lifecycle

Every API call goes through the same states: idle, loading, success, error. Model it once:

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };
Enter fullscreen mode Exit fullscreen mode

Usage:

function useUsers() {
  const [state, setState] = useState<AsyncState<User[]>>({ status: "idle" });

  const fetchUsers = async () => {
    setState({ status: "loading" });
    try {
      const data = await api.getUsers();
      setState({ status: "success", data });
    } catch (err) {
      setState({ status: "error", error: String(err) });
    }
  };

  return { state, fetchUsers };
}
Enter fullscreen mode Exit fullscreen mode

The render logic is a clean switch statement — no if (data && !error && !loading) chains:

function UserList() {
  const { state, fetchUsers } = useUsers();

  switch (state.status) {
    case "idle":
    case "loading":
      return <Spinner />;
    case "success":
      return <Table rows={state.data} />;
    case "error":
      return <ErrorBanner message={state.error} onRetry={fetchUsers} />;
  }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Form Submission States

Forms have their own lifecycle with richer states:

type FormState<T> =
  | { status: "editing"; dirty: boolean }
  | { status: "submitting" }
  | { status: "validation_error"; errors: FieldErrors<T> }
  | { status: "success"; result: SubmissionResult }
  | { status: "error"; message: string };
Enter fullscreen mode Exit fullscreen mode

Each variant carries only the data it needs. validation_error carries per-field errors but no submission result. submitting carries nothing — the form is in flight. The variant's shape documents the state better than any comment.

function SubmitButton({ state }: { state: FormState<any> }) {
  switch (state.status) {
    case "editing":
      return <Button disabled={!state.dirty}>Save</Button>;
    case "submitting":
      return <Button loading>Saving...</Button>;
    case "validation_error":
      return <Button disabled>Fix errors to save</Button>;
    case "success":
      return <Button variant="success">Saved </Button>;
    case "error":
      return <Button variant="warning">Retry</Button>;
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice: state.dirty only exists in the "editing" branch. You can't accidentally read dirty in "submitting". The compiler prevents it.

Pattern 3: Modal and Dialog State

Modal state management often devolves into a boolean flag plus ad-hoc data. A discriminated union eliminates the disconnect:

type ModalState =
  | { type: "closed" }
  | { type: "confirm_delete"; item: Item }
  | { type: "edit_item"; item: Item; field: keyof Item }
  | { type: "bulk_action"; selectedIds: string[]; action: BulkAction };
Enter fullscreen mode Exit fullscreen mode
function App() {
  const [modal, setModal] = useState<ModalState>({ type: "closed" });

  return (
    <div>
      {modal.type === "confirm_delete" && (
        <ConfirmDialog
          itemName={modal.item.name}   // ✅ exists in this variant
          onConfirm={() => deleteItem(modal.item)}
          onCancel={() => setModal({ type: "closed" })}
        />
      )}
      {modal.type === "edit_item" && (
        <EditDialog
          item={modal.item}
          initialField={modal.field}    // ✅ exists only in edit variant
          onClose={() => setModal({ type: "closed" })}
        />
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Four modal states. One type. Zero unexpected nulls. If you add a new modal variant, TypeScript forces you to handle it everywhere ModalState is checked.

The Compiler-Enforced Switch: Exhaustive Checking

The real power comes from combining discriminated unions with the never type for exhaustiveness:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

function render(response: APIResponse): React.ReactNode {
  switch (response.status) {
    case "loading":
      return <Spinner />;
    case "success":
      return <List items={response.data} />;
    case "error":
      return <Error message={response.error} />;
    default:
      return assertNever(response); // ✅ compile error if a case is missing
  }
}
Enter fullscreen mode Exit fullscreen mode

If you add a new variant — say { status: "cached"; data: User[]; timestamp: number } — the default branch catches it at compile time. You can't forget to update the switch statement. The error message points directly to the assertNever call with the unhandled variant type.

This is the type of safety that runtime checks can never provide.

Bonus: Type-Narrowing Helpers

For cleaner access in React render logic, wrap the narrowing in a small helper:

function isStatus<T extends { status: string }>(
  state: T,
  status: T["status"]
): state is T & { status: typeof status } {
  return state.status === status;
}
Enter fullscreen mode Exit fullscreen mode

Or simpler — just use match from ts-pattern for pattern matching that feels like Rust or OCaml:

import { match } from "ts-pattern";

match(response)
  .with({ status: "loading" }, () => <Spinner />)
  .with({ status: "success" }, ({ data }) => <List items={data} />)
  .with({ status: "error" }, ({ error }) => <Error message={error} />)
  .exhaustive(); // compile error if not all cases handled
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Discriminated Unions

You don't need discriminated unions for everything:

  • Two optional fields that are genuinely independent. If a user can have both a nickname and a bio set independently, an object with two optional fields is fine.
  • Small, local state (one or two booleans). A const [isOpen, setOpen] = useState(false) is fine for a single toggle.
  • Performance-critical hot paths — object creation for every state transition has overhead. Profile first, then optimize.

The pattern shines when a type represents mutually exclusive states that share a common interface.

The Bottom Line

Discriminated unions turn runtime errors into compile errors. Every state variant declares exactly what data it carries. Every consumer must handle every variant — the compiler enforces it.

The next time you write a type with three nullable fields and a boolean flag, stop. That type doesn't describe your domain — it describes your bugs waiting to happen. Make impossible states impossible instead.

Top comments (0)