Discriminated Unions for UI State
Model loading, success, and error as mutually exclusive variants so TypeScript and your UI cannot show impossible states.
- typescript
- discriminated-unions
UI state goes wrong when fields that should never coexist all sit optional on one object:
type Bad = {
loading: boolean;
error?: string;
data?: User;
};
// loading + data + error all true-ish — valid type, invalid product
A discriminated union (tagged union) makes variants exclusive. The discriminant field tells TypeScript which properties exist.
Docs: Narrowing — Discriminated unions, Type narrowing.
The pattern
type RemoteData<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function UserCard({ state }: { state: RemoteData<User> }) {
switch (state.status) {
case 'idle':
return <p>Pick a user</p>;
case 'loading':
return <p role="status">Loading…</p>;
case 'success':
return <h2>{state.data.name}</h2>; // data guaranteed
case 'error':
return <p role="alert">{state.error.message}</p>;
}
}
status is the discriminant. After case 'success', state.data exists; state.error does not.
Why not booleans + optionals?
// Ambiguous
if (!loading && data) { /* success? or stale data during error? */ }
Teams invent informal rules (“ignore data when error is set”). Compilers don’t enforce folklore. Discriminants do.
Async reducer / useState
type Action<T> =
| { type: 'fetch' }
| { type: 'success'; data: T }
| { type: 'failure'; error: Error }
| { type: 'reset' };
function reduce<T>(state: RemoteData<T>, action: Action<T>): RemoteData<T> {
switch (action.type) {
case 'fetch':
return { status: 'loading' };
case 'success':
return { status: 'success', data: action.data };
case 'failure':
return { status: 'error', error: action.error };
case 'reset':
return { status: 'idle' };
}
}
Same idea for forms: 'editing' | 'submitting' | 'submitted' | 'invalid'.
Multi-field discriminants
Prefer one tag. Nested exclusivity:
type ModalState =
| { open: false }
| { open: true; title: string; mode: 'create' | 'edit'; id?: string };
When open: false, no title required — the closed modal can’t be half-configured.
Exhaustiveness
Pair with never so new variants fail compile:
function assertNever(x: never): never {
throw new Error(`unexpected: ${JSON.stringify(x)}`);
}
function label(state: RemoteData<unknown>): string {
switch (state.status) {
case 'idle':
return 'Idle';
case 'loading':
return 'Loading';
case 'success':
return 'OK';
case 'error':
return 'Error';
default:
return assertNever(state);
}
}
See never type and exhaustiveness.
React Query / SWR mapping
External libraries already expose status-like APIs. Map them once at the boundary:
function toRemote<T>(q: {
isPending: boolean;
isError: boolean;
isSuccess: boolean;
data: T | undefined;
error: Error | null;
}): RemoteData<T> {
if (q.isPending) return { status: 'loading' };
if (q.isError) return { status: 'error', error: q.error ?? new Error('unknown') };
if (q.isSuccess && q.data !== undefined) return { status: 'success', data: q.data };
return { status: 'idle' };
}
Don’t re-derive loading && !error && data ad hoc in every component.
Serialization note
JSON preserves the tag. Prefer string discriminants over symbols. On the wire, validate with a schema before trusting status.
Footguns
| Footgun | Fix |
|---|---|
| Optional data on every variant | Put data only on success |
Boolean isX flags beside a tag |
One source of truth |
| Discriminant not a literal type | Use string literal union, not string |
Forgetting default/never |
Exhaustiveness breaks silently |
// Weak discriminant
type W = { status: string; data?: User }; // status is any string
Interview out-loud answer
“I model async UI as a discriminated union with a status tag so success data and errors can’t coexist. Switch on the tag; TypeScript narrows. I use never in the default branch for exhaustiveness when we add states later.”
Related on this site
- Type narrowing
- never type and exhaustiveness
- Union and intersection types
- Literal types
- Typing fetch responses
Further reading
Related guides
- Basic Types and AnnotationsPrimitives, arrays, objects, and function annotations TypeScript actually checks — plus where inference is enough and where it is not.
- Branded Types for IDsNominal-style UserId vs OrderId in TypeScript — prevent ID mixups at compile time with brands, parsers, and form boundaries.
- Conditional Types IntroT extends U ? X : Y — how TypeScript picks types from conditions, distributes over unions, and powers utility types you already use.
- Declaration Files and DefinitelyTypedHow .d.ts files describe JS to TypeScript, when to use @types packages, module augmentation, and writing minimal ambient types for untyped libs.
- Enums vs Union TypesWhen TypeScript enums help, when they hurt, and why string literal unions plus const objects win for most frontend code.