DEV Community

Soumaya Erradi
Soumaya Erradi

Posted on

Discriminated Unions in TypeScript explained simply

The idea

Many apps track “where we are” with loose flags:

let isLoading = false;
let data: User | null = null;
let error: string | null = null;
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode
  • 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 };
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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'); }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}`,
});
Enter fullscreen mode Exit fullscreen mode

(This is optional, plain switch is perfectly fine.)


Migrating your code

  1. List current flags that describe modes: isLoading, hasError, status, etc.
  2. Name states that cover reality, e.g. idle | loading | success | error.
  3. Move fields into their states (error message inside 'error', data inside 'success').
  4. Replace if with switch (state.kind).
  5. 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');
});
Enter fullscreen mode Exit fullscreen mode

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 a default: 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)