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() }), ]);