The idea
Many apps track “where we are” with loose flags:
let isLoading = false;
let data: User | null = null;
let error: string | null = null;
This creates tricky situations: what if isLoading
is true
and error
is also set? Which one is real?
Discriminated unions fix this by saying: “the app is in exactly one state at a time, and each state carries only the data it needs.”
type FetchState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T }
| { kind: 'error'; message: string };
- The shared field (
kind
) is the discriminant. - The other fields belong only to that single state.
Now impossible combos (loading + error + data) simply can’t exist.
Why this helps
-
Clarity: You read
kind
and instantly know what’s happening. -
Safety: TypeScript narrows types for you. In
'success'
,data
is guaranteed to exist. - Guidance: If you add a new state later, the compiler shows every place you must update.
How to use it
1) Define the states
Pick a single property name for the tag. Common choices: kind
or type
.
type AuthState =
| { kind: 'loggedOut' }
| { kind: 'loggingIn' }
| { kind: 'loggedIn'; user: { id: string; name: string } }
| { kind: 'loginFailed'; message: string };
2) Write code that switches on the tag
function renderAuth(s: AuthState): string {
switch (s.kind) {
case 'loggedOut': return 'Please log in';
case 'loggingIn': return 'Logging in…';
case 'loggedIn': return `Welcome, ${s.user.name}`;
case 'loginFailed':return `Oops: ${s.message}`;
// add this to catch missing cases:
default: return assertNever(s);
}
}
function assertNever(x: never): never {
throw new Error('Unhandled state: ' + JSON.stringify(x));
}
TypeScript now narrows the type inside each case:
- In
'loggedIn'
,s.user
is available (no?
). - In
'loginFailed'
,s.message
is available.
3) Update state with small functions
function startLogin(_s: AuthState): AuthState {
return { kind: 'loggingIn' };
}
function loginOk(_s: AuthState, user: { id: string; name: string }): AuthState {
return { kind: 'loggedIn', user };
}
function loginFail(_s: AuthState, message: string): AuthState {
return { kind: 'loginFailed', message };
}
Keep these functions pure (no network calls inside). They’re easy to test.
Common everyday patterns
A) Async fetch without isLoading
type FetchState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T }
| { kind: 'error'; message: string; retryAfterMs?: number };
Use it in UI:
function renderUsers(s: FetchState<User[]>) {
switch (s.kind) {
case 'idle': return 'Click to load';
case 'loading': return 'Loading…';
case 'success': return `Loaded ${s.data.length} users`;
case 'error': return `Error: ${s.message}`;
default: return assertNever(s);
}
}
B) Forms as small flows
type ProfileForm =
| { kind: 'editing'; values: { name: string; email: string }; errors?: Record<string, string> }
| { kind: 'submitting'; values: { name: string; email: string } }
| { kind: 'submitted'; id: string }
| { kind: 'failed'; values: { name: string; email: string }; message: string };
Now you don’t need isSubmitting
, submitError
, etc. The state name says it all.
C) Feature flags you can’t misuse
type Rollout =
| { kind: 'off' }
| { kind: 'percentage'; percent: number } // 0..100
| { kind: 'audience'; segments: Array<'beta'|'staff'|'pro'> }
| { kind: 'on' };
function isEnabled(flag: Rollout, ctx: { segment: string; rand: number }) {
switch (flag.kind) {
case 'off': return false;
case 'on': return true;
case 'percentage': return ctx.rand < flag.percent / 100;
case 'audience': return flag.segments.includes(ctx.segment as any);
default: return assertNever(flag);
}
}
D) Results and Options (errors without try/catch
everywhere)
type Result<T, E> = { kind: 'ok'; value: T } | { kind: 'err'; error: E };
type Option<T> = { kind: 'some'; value: T } | { kind: 'none' };
const ok = <T, E=never>(value: T): Result<T, E> => ({ kind: 'ok', value });
const err = <E, T=never>(error: E): Result<T, E> => ({ kind: 'err', error });
const some = <T>(value: T): Option<T> => ({ kind: 'some', value });
const none = <T=never>(): Option<T> => ({ kind: 'none' });
function parseJson<T>(s: string): Result<T, string> {
try { return ok(JSON.parse(s) as T); }
catch (e) { return err('Invalid JSON'); }
}
Helpful add-ons
Small type guards (nice for readability)
const isSuccess = <T>(s: FetchState<T>): s is { kind: 'success'; data: T } =>
s.kind === 'success';
if (isSuccess(state)) {
// state.data is available and typed
}
Tiny matcher (for one-line mappings)
function match<T extends { kind: string }, R>(
v: T,
handlers: { [K in T['kind']]: (x: Extract<T, { kind: K }>) => R }
): R {
return handlers[v.kind](v as any);
}
const label = match(state, {
idle: () => 'Idle',
loading: () => 'Loading…',
success: s => `Got ${s.data.length}`,
error: s => `Error: ${s.message}`,
});
(This is optional, plain switch
is perfectly fine.)
Migrating your code
-
List current flags that describe modes:
isLoading
,hasError
,status
, etc. -
Name states that cover reality, e.g.
idle | loading | success | error
. -
Move fields into their states (error message inside
'error'
, data inside'success'
). -
Replace
if
withswitch (state.kind)
. -
Add
assertNever
to catch missing cases now and in the future.
Do this one module at a time. You’ll see benefits immediately.
Testing becomes easier
Because your state-changing functions are pure (input → output, no side effects), tests are short and stable.
import { it, expect } from 'vitest';
it('goes loading -> success', () => {
const s0: FetchState<User> = { kind: 'idle' };
const s1: FetchState<User> = { kind: 'loading' };
const s2: FetchState<User> = { kind: 'success', data: { id: '1', name: 'Soumaya' } };
expect(s0.kind).toBe('idle');
expect(s1.kind).toBe('loading');
expect(s2.kind).toBe('success');
});
No mocking of timers or network required to test state transitions.
Common mistakes
-
Different tag names. All variants must share the same discriminant name (e.g., always
kind
). -
Optional fields everywhere. If a field only matters in one situation, make it its own state instead of
field?: T
. - Too many tiny states. If two states act the same and hold the same data, merge them.
-
Forgetting exhaustiveness. Keep
assertNever
(or adefault: exhaustive(state)
helper) to catch missing cases.
Final thoughts
Discriminated unions are small but powerful:
- They name your states.
- They make impossible states impossible.
- They give you clear code and better autocomplete.
- They make changes safe (the compiler shows what to update).
Start with one place that currently uses isLoading + error + data?
. Turn it into a union with kind
. You’ll feel the difference right away.
Top comments (0)