Modelling state with discriminated unions in TypeScript
A surprising number of bugs come from states that should never coexist. A request that is both loading and errored. A form that is submitting and has a result. Discriminated union…
A surprising number of bugs come from states that should never coexist. A request that is both loading and errored. A form that is submitting and has a result. Discriminated unions let you describe state so that the impossible combinations do not type check.
The problem with optional fields
Here is the shape a lot of code starts with:
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}Nothing stops loading being true while data is also set, and every read of data needs a guard. The type permits states the program never intends to reach.
One tag to rule them
Give each state a literal status field and let the others depend on it:
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string };Now data only exists when status is success, and the compiler knows it. Narrowing on the tag unlocks the right fields:
function render(state: RequestState): string {
switch (state.status) {
case 'idle':
return 'Nothing requested yet';
case 'loading':
return 'Loading...';
case 'success':
return state.data.name;
case 'error':
return state.message;
}
}Make the compiler check completeness
Add an exhaustiveness guard so a new state cannot be forgotten:
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}Call it in the default branch of the switch. The day someone adds a cancelled status, every switch that forgot to handle it becomes a compile error rather than a silent gap.
When to reach for it
Discriminated unions pay off whenever a value moves through distinct phases: request state, parser results, view models, message protocols. If you find yourself writing comments about which fields are valid together, that is the signal to encode it in the type instead.
