TypeScript Discriminated Unions: Make Impossible UI States Impossible

Suman SharmaSuman Sharma

Here's a type I've seen in almost every codebase I've joined:

type State = { isLoading: boolean; isError: boolean; error?: string; data?: Project[]; };

Four fields, sixteen combinations, most of them nonsense. isLoading: true with isError: true and data set? The type allows it. Your UI will eventually render it.

The fix

type State = | { status: "idle" } | { status: "loading" } | { status: "error"; error: string } | { status: "success"; data: Project[] };

Four states, four shapes. data only exists on success. error only exists on error.

Narrowing in components

function Projects({ state }: { state: State }) { switch (state.status) { case "idle": return null; case "loading": return <Spinner />; case "error": return <ErrorMessage message={state.error} />; case "success": return <ProjectList projects={state.data} />; default: return assertNever(state); } } function assertNever(x: never): never { throw new Error(`Unhandled state: ${JSON.stringify(x)}`); }

Add { status: "empty" } later and TypeScript fails the build until every switch handles it.

Beyond loading states

Discriminated unions shine for:

  • Form steps: { step: "details" } | { step: "payment"; plan: Plan } | { step: "done"; receiptId: string }
  • API results: { ok: true; value: T } | { ok: false; error: AppError }
  • Events and actions in reducers
  • Domain status: a plot is "unvalidated" | "valid" | "invalid" with different data per status, a pattern I use in compliance tooling (plot validation)

Validating at the boundary

Unions describe your internal state. Data from APIs still needs runtime validation. Zod's z.discriminatedUnion maps directly:

const Result = z.discriminatedUnion("status", [ z.object({ status: z.literal("ok"), data: z.array(ProjectSchema) }), z.object({ status: z.literal("error"), message: z.string() }), ]);

Frequently asked questions

What is a discriminated union?

A union of object types that share a literal property, the discriminant (often called 'status' or 'type'). TypeScript uses that property to narrow which variant you have.

What is an exhaustive check?

Assigning the value to a variable of type never in the default branch of a switch. If you add a new variant and forget to handle it, TypeScript reports an error.

When should I use them instead of optional fields?

Whenever certain fields only exist in certain states, such as data only on success or error only on failure. Unions make invalid combinations unrepresentable.

Contact

Get in Touch

Want to call me? book a call and I'll respond in the available time slot. I will ignore all soliciting.