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;
};
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
}
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 };
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
}
}
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 };
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 };
}
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} />;
}
}
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 };
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>;
}
}
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 };
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>
);
}
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
}
}
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;
}
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
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
nicknameand abioset 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)