| |

TypeScript 16 ๐Ÿ”ท Discriminated Unions

A discriminated union is a union of object types that share a common literal property โ€” the discriminant โ€” which tells TypeScript which branch you’re on. Narrowing on that property narrows the entire object, giving you access to the branch-specific properties. It’s one of TypeScript’s most powerful patterns, and once you see it, you’ll find it models almost every “one of several shapes” problem in real code โ€” results, events, states, commands, API responses, and beyond.

Key point: A discriminated union has three parts: a union of object types, a shared property whose type is a literal, and narrowing on that property. The literal discriminant is what makes narrowing work โ€” status: string won’t narrow, status: 'success' will. Every time you catch yourself writing if ('data' in result) or casting between branches, a discriminated union is probably the better tool.


What a discriminated union is

Take a union of object types. Give each branch a property whose type is a distinct literal. TypeScript now knows exactly which branch a value is in when you check that property.

type Result =
  | { status: 'success'; data: string }
  | { status: 'error'; message: string }
  | { status: 'loading' };

Three branches. The status field is the discriminant. Each branch has a literal type for it โ€” 'success', 'error', 'loading' โ€” and other properties that exist only on that branch.

function handle(r: Result): string {
  switch (r.status) {
    case 'success':
      return r.data;              // r is { status: 'success'; data: string }
    case 'error':
      return r.message;           // r is { status: 'error'; message: string }
    case 'loading':
      return 'Loading...';        // r is { status: 'loading' }
  }
}

Inside each case, TypeScript narrows r to the exact branch. r.data is only valid in the success case; r.message only in error. The compiler rejects accesses to properties that don’t exist on the narrowed branch.

The three requirements:

  1. A union of object types โ€” the branches
  2. A shared property โ€” the discriminant, present on every branch
  3. A literal type for that property โ€” each branch has a distinct literal

Meet all three, and narrowing works automatically.

Why “discriminated”: The discriminant discriminates โ€” it tells the branches apart. It’s a single field whose literal value identifies which shape you have. Without a discriminant, TypeScript can’t narrow a union of object types beyond the common properties.


Why the discriminant must be a literal

The literal type is what makes narrowing possible.

// โŒ Won't narrow
type Bad =
  | { status: string; data: string }
  | { status: string; message: string };

function handle(r: Bad): void {
  if (r.status === 'success') {
    // r is still the full union โ€” no narrowing
  }
}

status: string is the same type in both branches โ€” TypeScript can’t tell them apart. The check is meaningless for narrowing.

// โœ… Narrows
type Good =
  | { status: 'success'; data: string }
  | { status: 'error'; message: string };

function handle(r: Good): void {
  if (r.status === 'success') {
    // r is { status: 'success'; data: string }
  }
}

status: 'success' only matches one branch. The check narrows.

Boolean discriminants work too:

type Response =
  | { ok: true; value: string }
  | { ok: false; error: string };

function handle(r: Response): void {
  if (r.ok) {
    r.value;                      // โœ…
  } else {
    r.error;                      // โœ…
  }
}

true and false are literal types. Narrowing works.

Number discriminants work too:

type Shape =
  | { kind: 1; radius: number }
  | { kind: 2; side: number };

function area(s: Shape): number {
  if (s.kind === 1) return Math.PI * s.radius ** 2;
  return s.side ** 2;
}

Numbers, strings, and booleans can all be discriminants. Strings are the most common because they’re self-documenting.

Why literals and not general types: Narrowing works by elimination. r.status === 'success' proves r is in the branch where status is 'success' โ€” because no other branch has that value. If both branches had status: string, the check would match both and prove nothing. The literal is what makes the check meaningful.


A real example โ€” network request state

The classic use case: modeling a request that can be loading, successful, or failed.

type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function render<T>(state: RequestState<T>): string {
  switch (state.status) {
    case 'idle':
      return 'Not started';
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Loaded: ${JSON.stringify(state.data)}`;
    case 'error':
      return `Error: ${state.error}`;
  }
}

Every state is a distinct branch. The data property exists only in success; the error property only in error. TypeScript refuses to compile state.data when the status is 'loading'.

Why this beats the alternative:

// โŒ Without discriminated union
interface BadState<T> {
  loading: boolean;
  data?: T;
  error?: string;
}

With the interface version, every combination is possible โ€” loading: true with data set and error set. TypeScript can’t tell you which combination is valid. The discriminated union makes illegal states impossible to represent.

That’s the real value: not just narrowing, but impossible states are rejected by the compiler. You can’t construct a success without data. You can’t construct an error without message. The type system enforces the rules.

Why “impossible states” matter: Bugs come from states that shouldn’t exist. If your type allows loading: true, data: {...}, error: '...' all at once, some code somewhere will produce that state, and some other code will misbehave. Discriminated unions eliminate that class of bugs at compile time โ€” the type refuses to represent the nonsense.


Exhaustiveness checks

Discriminated unions pair naturally with exhaustiveness. Add a default case that asserts never, and the compiler tells you when you’ve missed a branch.

function assertNever(x: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}

type Status = 'idle' | 'loading' | 'ready' | 'error';

function message(s: Status): string {
  switch (s) {
    case 'idle': return 'Waiting';
    case 'loading': return 'Loading...';
    case 'ready': return 'Ready';
    case 'error': return 'Failed';
    default: return assertNever(s);
  }
}

If you add a new status and forget to handle it, s isn’t never in the default branch, and assertNever(s) fails to compile.

How it works:

  • never is the empty type โ€” no value belongs to it
  • After handling all branches, the remaining type is never
  • If a branch is missing, the remaining type is not never
  • Assigning a non-never to assertNever fails
type Status = 'a' | 'b' | 'c';

function f(s: Status): void {
  switch (s) {
    case 'a': break;
    case 'b': break;
    // โŒ 'c' not handled
    default: {
      const _exhaustive: never = s;  // error: s is 'c'
    }
  }
}

The default clause catches the missing case, and the assignment to never fails.

For discriminated unions specifically:

type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'keydown'; key: string }
  | { kind: 'scroll'; offset: number };

function handle(e: Event): void {
  switch (e.kind) {
    case 'click': use(e.x, e.y); break;
    case 'keydown': use(e.key); break;
    case 'scroll': use(e.offset); break;
    default: assertNever(e);
  }
}

Add a new event kind, and every handler using assertNever fails until updated.

Why this matters: Adding a variant is a compile-time event. You can’t forget to update a switch. The compiler becomes a checklist. For state machines, event handlers, and command dispatchers, that’s the difference between safe refactoring and silent bugs.

Why assertNever is idiomatic: It converts “I think I’ve handled all cases” into “the compiler proved it.” The function never actually runs when the union is fully handled โ€” but if you forget a branch, the compiler refuses the code. That’s a real proof, not a convention.


Discriminated unions vs in narrowing

You can narrow a union of objects with in too โ€” but discriminated unions are stricter.

// With `in`
type Response =
  | { data: string }
  | { error: string };

function handle(r: Response): string {
  if ('data' in r) return r.data;
  return r.error;
}

in works. But Response allows objects with both properties, or neither. TypeScript doesn’t prevent that. Discriminated unions do.

// With a discriminant
type Response =
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

function handle(r: Response): string {
  if (r.status === 'success') return r.data;
  return r.error;
}

Now a value can’t have both data and error, and it can’t have neither. The discriminant enforces mutual exclusion.

When in is the right tool:

  • The branches don’t share a natural literal field
  • You’re narrowing a library type you can’t change
  • You’re doing a one-off check

When to prefer discriminated unions:

  • You control the type
  • You want illegal states rejected
  • You want exhaustiveness checking
  • Multiple branches need to be distinguished

Why discriminate instead of in: in asks “does this property exist?” โ€” which can be true for several branches or none. A discriminant asks “which branch is this?” โ€” exactly one branch answers. It’s a stricter question, and stricter types mean fewer bugs.


Generic discriminated unions

Discriminated unions work with generics โ€” perfect for wrapping any payload type.

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function unwrap<T>(r: Result<T>): T {
  if (r.ok) return r.value;
  throw r.error;
}

function map<T, U>(r: Result<T>, fn: (v: T) => U): Result<U> {
  return r.ok ? { ok: true, value: fn(r.value) } : r;
}

Result<T> is a discriminated union parameterized by T โ€” the success payload โ€” and E โ€” the error payload. unwrap narrows on ok, and map transforms the success without touching the failure.

Async version:

async function fetchUser(id: number): Promise<Result<User>> {
  try {
    const res = await fetch(`/users/${id}`);
    if (!res.ok) return { ok: false, error: new Error(res.statusText) };
    return { ok: true, value: await res.json() as User };
  } catch (e) {
    return { ok: false, error: e as Error };
  }
}

Every path returns a Result. Callers narrow on ok.

Why generics matter: A Result<T> type scales across every success payload. Result<User>, Result<Product>, Result<string[]> โ€” same shape, different payload. The discriminated union provides the branching; the generic provides the flexibility.

Why Result types are idiomatic: Exceptions in JavaScript can be thrown from anywhere and caught anywhere. Result types make failure explicit โ€” the caller must handle both branches, and the compiler enforces it. Combined with discriminated unions, they’re a common pattern in typed code.


Discriminated unions in practice

The pattern shows up everywhere once you know it.

Redux-style actions:

type Action =
  | { type: 'ADD'; item: string }
  | { type: 'REMOVE'; id: number }
  | { type: 'CLEAR' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'ADD': return { ...state, items: [...state.items, action.item] };
    case 'REMOVE': return { ...state, items: state.items.filter(i => i.id !== action.id) };
    case 'CLEAR': return { ...state, items: [] };
    default: return assertNever(action);
  }
}

API responses:

type ApiResponse<T> =
  | { status: 200; body: T }
  | { status: 400; error: string }
  | { status: 404; error: string }
  | { status: 500; error: string };

Form state:

type FormState<T> =
  | { state: 'pristine'; values: T }
  | { state: 'dirty'; values: T; changes: Partial<T> }
  | { state: 'submitting'; values: T }
  | { state: 'submitted'; values: T; result: Result<T> };

Command dispatch:

type Command =
  | { kind: 'create'; data: NewItem }
  | { kind: 'update'; id: string; patch: Partial<Item> }
  | { kind: 'delete'; id: string };

async function execute(cmd: Command): Promise<void> {
  switch (cmd.kind) {
    case 'create': return api.create(cmd.data);
    case 'update': return api.update(cmd.id, cmd.patch);
    case 'delete': return api.delete(cmd.id);
  }
}

Why the pattern repeats: Every system has “one of several shapes” somewhere โ€” actions, events, states, commands, responses. Discriminated unions model that shape exactly, with narrowing and exhaustiveness. Once you internalize the pattern, you’ll see it everywhere.

Why the discriminant naming varies: kind is common for shapes, type for actions, status for state, ok for results, tag for tagged unions. The name doesn’t matter โ€” the pattern does. What matters is that the property is a literal and every branch has a distinct value.


Constructing discriminated unions safely

Creating a value of a discriminated union is straightforward โ€” pick a branch and provide its properties.

type Result =
  | { status: 'success'; data: string }
  | { status: 'error'; message: string };

const ok: Result = { status: 'success', data: 'hello' };   // โœ…
const err: Result = { status: 'error', message: 'oops' };  // โœ…

The compiler rejects invalid combinations:

// โŒ missing property
const bad1: Result = { status: 'success' };                    // โŒ

// โŒ wrong property for the branch
const bad2: Result = { status: 'success', message: 'x' };      // โŒ

// โŒ unknown discriminant value
const bad3: Result = { status: 'unknown' };                    // โŒ

Helping functions build branches:

function success(data: string): Result {
  return { status: 'success', data };
}

function failure(message: string): Result {
  return { status: 'error', message };
}

These make construction concise and prevent typos in the discriminant.

Why helper functions help: Writing { status: 'success', data } everywhere gets old. A factory function centralizes the shape and updates it if the branch changes. It’s a small pattern that pays off in larger codebases.

Why construction is checked: The union type describes valid values. A success without data isn’t a valid value. TypeScript enforces this by rejecting the object. You can’t accidentally construct an invalid branch โ€” the compiler catches it at the assignment.


A full example

A complete parser result type with exhaustiveness and safety.

// ============================================
// THE UNION
// ============================================

type ParseResult<T> =
  | { kind: 'ok'; value: T }
  | { kind: 'empty' }
  | { kind: 'error'; message: string; offset: number };

// ============================================
// CONSTRUCTORS
// ============================================

function ok<T>(value: T): ParseResult<T> {
  return { kind: 'ok', value };
}

function empty<T>(): ParseResult<T> {
  return { kind: 'empty' };
}

function error<T>(message: string, offset: number): ParseResult<T> {
  return { kind: 'error', message, offset };
}

// ============================================
// HANDLERS
// ============================================

function unwrap<T>(r: ParseResult<T>): T {
  switch (r.kind) {
    case 'ok':
      return r.value;
    case 'empty':
      throw new Error('Empty result');
    case 'error':
      throw new Error(`${r.message} at ${r.offset}`);
    default:
      return assertNever(r);
  }
}

function describe<T>(r: ParseResult<T>): string {
  switch (r.kind) {
    case 'ok':
      return `Parsed: ${JSON.stringify(r.value)}`;
    case 'empty':
      return 'No input';
    case 'error':
      return `Error at position ${r.offset}: ${r.message}`;
    default:
      return assertNever(r);
  }
}

function assertNever(x: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}

// ============================================
// USAGE
// ============================================

const results: ParseResult<number>[] = [
  ok(42),
  empty(),
  error('Unexpected token', 10)
];

for (const r of results) {
  console.log(describe(r));
}

// Output:
// Parsed: 42
// No input
// Error at position 10: Unexpected token

Every pattern is present: a generic discriminated union, factory constructors, exhaustive handlers with assertNever, and the whole thing works safely across branches.

What this demonstrates:

  • The kind discriminant picks the branch
  • Each branch has properties unique to it
  • unwrap and describe exhaustively handle all cases
  • Adding a new kind triggers compile errors in both functions

Why this shape: It’s a small parser-result model. The discriminant is kind, the payloads differ per branch, and every consumer must handle all cases. That’s the pattern in miniature โ€” it scales to any domain.


Complete Example Session

# ============================================
# PART 1: BASIC DISCRIMINATED UNION
# ============================================

cat > basics.ts << 'EOF'
type Result =
  | { status: 'success'; data: string }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function handle(r: Result): string {
  switch (r.status) {
    case 'success': return r.data;
    case 'error': return r.message;
    case 'loading': return 'Loading...';
  }
}

console.log(handle({ status: 'success', data: 'hello' }));
console.log(handle({ status: 'error', message: 'oops' }));
console.log(handle({ status: 'loading' }));
EOF

npx tsc --noEmit basics.ts
# (no errors)

# ============================================
# PART 2: TRIGGER A TYPE ERROR
# ============================================

cat > errors.ts << 'EOF'
type Result =
  | { status: 'success'; data: string }
  | { status: 'error'; message: string };

function handle(r: Result): void {
  if (r.status === 'success') {
    console.log(r.message);  // โŒ 'message' not on success
  }
}
EOF

npx tsc --noEmit errors.ts
# [ errors.ts:7:19 - Property 'message' does not exist on type '{ status: "success"; data: string; }'. ]

rm errors.ts

# ============================================
# PART 3: EXHAUSTIVENESS
# ============================================

cat > exhaustive.ts << 'EOF'
type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'keydown'; key: string }
  | { kind: 'scroll'; offset: number };

function assertNever(x: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}

function handle(e: Event): string {
  switch (e.kind) {
    case 'click': return `click ${e.x},${e.y}`;
    case 'keydown': return `key ${e.key}`;
    case 'scroll': return `scroll ${e.offset}`;
    default: return assertNever(e);
  }
}

console.log(handle({ kind: 'click', x: 10, y: 20 }));
console.log(handle({ kind: 'keydown', key: 'Enter' }));
console.log(handle({ kind: 'scroll', offset: 500 }));
EOF

npx tsc --noEmit exhaustive.ts
# (no errors)

# ============================================
# PART 4: TRIGGER EXHAUSTIVENESS ERROR
# ============================================

cat > missing.ts << 'EOF'
type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'keydown'; key: string };

function assertNever(x: never): never { throw x; }

function handle(e: Event): void {
  switch (e.kind) {
    case 'click': break;
    // โŒ keydown not handled
    default: assertNever(e);
  }
}
EOF

npx tsc --noEmit missing.ts
# [ missing.ts:10:24 - Argument of type '{ kind: "keydown"; key: string; }' is not assignable to parameter of type 'never'. ]

rm missing.ts

# ============================================
# PART 5: GENERIC RESULT
# ============================================

cat > result.ts << 'EOF'
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function unwrap<T>(r: Result<T>): T {
  if (r.ok) return r.value;
  throw r.error;
}

function map<T, U>(r: Result<T>, fn: (v: T) => U): Result<U> {
  return r.ok ? { ok: true, value: fn(r.value) } : r;
}

const good: Result<number> = { ok: true, value: 42 };
const bad: Result<number> = { ok: false, error: new Error('oops') };

console.log(unwrap(good));
console.log(map(good, n => n * 2));
console.log(map(bad, n => n * 2));
EOF

npx tsc --noEmit result.ts
# (no errors)

# ============================================
# PART 6: FULL PARSER EXAMPLE
# ============================================

cat > parser.ts << 'EOF'
type ParseResult<T> =
  | { kind: 'ok'; value: T }
  | { kind: 'empty' }
  | { kind: 'error'; message: string; offset: number };

function ok<T>(value: T): ParseResult<T> { return { kind: 'ok', value }; }
function empty<T>(): ParseResult<T> { return { kind: 'empty' }; }
function error<T>(message: string, offset: number): ParseResult<T> {
  return { kind: 'error', message, offset };
}

function assertNever(x: never): never { throw x; }

function describe<T>(r: ParseResult<T>): string {
  switch (r.kind) {
    case 'ok': return `Parsed: ${JSON.stringify(r.value)}`;
    case 'empty': return 'No input';
    case 'error': return `Error at ${r.offset}: ${r.message}`;
    default: return assertNever(r);
  }
}

const results: ParseResult<number>[] = [
  ok(42),
  empty(),
  error('Unexpected token', 10)
];

for (const r of results) console.log(describe(r));
EOF

npx tsc --noEmit parser.ts
# (no errors)

# ============================================
# PART 7: COMPILE AND RUN
# ============================================

npx tsc basics.ts exhaustive.ts result.ts parser.ts
node basics.js
# [ hello ]
# [ oops ]
# [ Loading... ]

node exhaustive.js
# [ click 10,20 ]
# [ key Enter ]
# [ scroll 500 ]

node result.js
# [ 42 ]
# [ { ok: true, value: 84 } ]
# [ { ok: false, error: Error: oops } ]

node parser.js
# [ Parsed: 42 ]
# [ No input ]
# [ Error at 10: Unexpected token ]

Quick Reference

Anatomy

PartMeaning
Union of object typesThe branches
Discriminant propertyShared literal field
Literal typesDistinct value per branch

Common Discriminant Names

NameTypical use
kindShapes, variants
typeRedux actions, events
statusRequest states
stateForm/state machine
okResult success/failure
tagTagged unions

Narrowing Tools

ToolNarrows
switch (x.discriminant)Branch-specific
if (x.discriminant === 'a')One branch
x.discriminant !== 'a'Everything else

Exhaustiveness

StepCode
Helperfunction assertNever(x: never): never { throw x; }
Switch all casescase 'a': ... case 'b': ...
Defaultdefault: return assertNever(x);
Add variantCompile error in every switch

Discriminant Types

TypeWorks?
String literalโœ…
Number literalโœ…
Boolean literalโœ…
Enum memberโœ…
stringโŒ
numberโŒ

Generic Pattern

TypeMeaning
Result<T>Success/failure with payload
ApiResponse<T>HTTP response with body
Event<T>Discriminated event
Action<T>Redux-style action

Discriminated Union vs in

AspectDiscriminatedin
Requires literal fieldโœ…โŒ
Mutually exclusiveโœ…โŒ
Exhaustivenessโœ…โŒ
Works on third-party typesโŒโœ…
Illegal states preventedโœ…โŒ

Common Patterns

PatternDiscriminant
Request statestatus: 'idle' | 'loading' | 'success' | 'error'
Resultok: true | false
Eventkind: 'click' | 'keydown' | 'scroll'
Actiontype: 'ADD' | 'REMOVE' | 'CLEAR'
Shapekind: 'circle' | 'square'

Construction

FormCode
Inline{ kind: 'a', ... }
Factoryfunction a(): A { return { kind: 'a' }; }
Constconst A = { kind: 'a' } as const

Error Patterns

ErrorCause
Property 'x' does not existWrong branch for the property
Type 'string' not assignable to 'never'Missing exhaustiveness
Property 'discriminant' missingBranch without discriminant
Object literal may only specify known propertiesTypo in property name

When to Use

SituationDiscriminated union?
One-of-several shapesโœ…
State machineโœ…
API resultsโœ…
Events / actionsโœ…
Optional propertiesโš ๏ธ maybe
Simple union of primitivesโŒ โ€” plain union
Independent flagsโŒ โ€” separate fields

Best Practices

โœ… Do This:

// Use a literal discriminant on every branch
type Result =
  | { kind: 'ok'; value: string }
  | { kind: 'err'; message: string };                     // โœ…

// Use string literals over numbers
type Event = { kind: 'click' | 'keydown' };               // โœ…

// Name the discriminant meaningfully
type State = { status: 'idle' | 'loading' };              // โœ…

// Write exhaustive handlers
default: return assertNever(x);                           // โœ…

// Add factory functions for common branches
function ok<T>(value: T): Result<T> { return { kind: 'ok', value }; }  // โœ…

// Use generics for payload flexibility
type Result<T> = { ok: true; value: T } | { ok: false; error: string };  // โœ…

// Keep branches mutually exclusive
// No shared optional properties across branches           // โœ…

// Use `never` for exhaustiveness
function assertNever(x: never): never { throw x; }        // โœ…

// Model states, not flags
// Prefer { status: 'loading' } over { loading: true }     // โœ…

โŒ Don’t Do This:

// Don't use string (not literal) as a discriminant
type Bad = { status: string; data: string } | { status: string; error: string }; // โŒ

// Don't overlap discriminants
type Bad = { kind: 'a' | 'b'; x: number } | { kind: 'b'; y: number };            // โŒ

// Don't skip exhaustiveness
switch (e.kind) {
  case 'a': break;
  // โŒ b silently ignored
}                                                                                 // โŒ

// Don't allow impossible states
interface Bad { loading: boolean; data?: T; error?: string }                      // โš ๏ธ

// Don't use `in` when a discriminant would work
if ('data' in r) { }                                                              // โš ๏ธ

// Don't use `as` to force a branch
const s = r as { data: string };                                                  // โŒ

// Don't construct invalid branches
const bad: Result = { kind: 'ok' };  // โŒ missing value                           // โŒ

// Don't forget the default branch
switch (e.kind) {
  case 'a': return 1;
  case 'b': return 2;
  // no default
}                                                                                 // โš ๏ธ

Common Pitfalls

PitfallProblemSolution
string discriminantNo narrowingUse literals
Missing discriminant on a branchUnion doesn’t narrowAdd it to every branch
Overlapping literal valuesAmbiguous narrowingKeep values distinct
No exhaustiveness checkMissing casesassertNever in default
Illegal state combinationsBugsDiscriminated union instead of optional flags
Typos in discriminantCompile error or wrong branchString literals catch typos
Using as to force a branchHidden bugsNarrow properly
Forgetting the discriminant when constructingCompile errorInclude it

Real-World Examples

1. Basic result

type Result =
  | { ok: true; value: string }
  | { ok: false; error: string };

2. Request state

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string };

3. Event

type Event =
  | { kind: 'click'; x: number; y: number }
  | { kind: 'keydown'; key: string };

4. Action

type Action =
  | { type: 'add'; item: string }
  | { type: 'remove'; id: number }
  | { type: 'clear' };

5. API response

type ApiResponse<T> =
  | { status: 200; body: T }
  | { status: 400; error: string }
  | { status: 500; error: string };

6. Shape

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

7. Form state

type Form =
  | { state: 'pristine'; values: Values }
  | { state: 'dirty'; values: Values; changes: Partial<Values> }
  | { state: 'submitting'; values: Values };

8. Command

type Command =
  | { kind: 'create'; data: NewItem }
  | { kind: 'delete'; id: string };

9. Notification

type Notice =
  | { type: 'info'; message: string }
  | { type: 'success'; message: string }
  | { type: 'error'; message: string; code: number };

10. Route

type Route =
  | { name: 'home' }
  | { name: 'user'; id: string }
  | { name: 'search'; q: string; page?: number };

11. Auth state

type Auth =
  | { status: 'anonymous' }
  | { status: 'authenticated'; user: User }
  | { status: 'expired'; reason: string };

12. Payment

type Payment =
  | { method: 'card'; last4: string }
  | { method: 'paypal'; email: string }
  | { method: 'bank'; iban: string };

13. Handler

function handle<T>(r: Result<T>): T {
  if (r.ok) return r.value;
  throw new Error(r.error);
}

14. Exhaustive switch

switch (e.kind) {
  case 'a': return 1;
  case 'b': return 2;
  case 'c': return 3;
  default: return assertNever(e);
}

15. Generic result

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

16. Async wrapper

async function fetchIt(): Promise<Result<User>> {
  try { return { ok: true, value: await api.get() }; }
  catch (e) { return { ok: false, error: e as Error }; }
}

17. Factory function

function ok<T>(value: T): Result<T> { return { ok: true, value }; }

18. Filter by branch

const successes = results.filter((r): r is { ok: true; value: T } => r.ok);

19. Map over success

function map<T, U>(r: Result<T>, fn: (v: T) => U): Result<U> {
  return r.ok ? { ok: true, value: fn(r.value) } : r;
}

20. Chain

function chain<T, U>(r: Result<T>, fn: (v: T) => Result<U>): Result<U> {
  return r.ok ? fn(r.value) : r;
}

Visual: Anatomy

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  type Result =                               โ”‚
โ”‚    | { status: 'success'; data: string }     โ”‚
โ”‚    | { status: 'error'; message: string }    โ”‚
โ”‚    | { status: 'loading' };                  โ”‚
โ”‚                                              โ”‚
โ”‚  Common property:  status                    โ”‚
โ”‚  Literal values:   'success' | 'error' |     โ”‚
โ”‚                    'loading'                 โ”‚
โ”‚  Branch-specific:  data, message             โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Narrowing

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  r: Result                                   โ”‚
โ”‚                                              โ”‚
โ”‚  switch (r.status) {                         โ”‚
โ”‚    case 'success':                           โ”‚
โ”‚      // r: { status: 'success'; data: string }โ”‚
โ”‚      console.log(r.data);                    โ”‚
โ”‚                                              โ”‚
โ”‚    case 'error':                             โ”‚
โ”‚      // r: { status: 'error'; message: string}โ”‚
โ”‚      console.log(r.message);                 โ”‚
โ”‚                                              โ”‚
โ”‚    case 'loading':                           โ”‚
โ”‚      // r: { status: 'loading' }             โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Exhaustiveness

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  type Event =                                โ”‚
โ”‚    | { kind: 'a' }                           โ”‚
โ”‚    | { kind: 'b' }                           โ”‚
โ”‚    | { kind: 'c' };                          โ”‚
โ”‚                                              โ”‚
โ”‚  switch (e.kind) {                           โ”‚
โ”‚    case 'a': break;                          โ”‚
โ”‚    case 'b': break;                          โ”‚
โ”‚    case 'c': break;                          โ”‚
โ”‚    default:                                  โ”‚
โ”‚      // e: never โ€” everything covered        โ”‚
โ”‚      assertNever(e);                         โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Add 'd' to Event:                           โ”‚
โ”‚                                              โ”‚
โ”‚  switch (e.kind) {                           โ”‚
โ”‚    case 'a': ... case 'b': ... case 'c': ... โ”‚
โ”‚    default:                                  โ”‚
โ”‚      // e: { kind: 'd' } โ€” not never         โ”‚
โ”‚      assertNever(e);  // โŒ compile error    โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Discriminated Union vs Flags

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โŒ Flag-based state                         โ”‚
โ”‚                                              โ”‚
โ”‚  interface State {                           โ”‚
โ”‚    loading: boolean;                         โ”‚
โ”‚    data?: User;                              โ”‚
โ”‚    error?: string;                           โ”‚
โ”‚  }                                           โ”‚
โ”‚                                              โ”‚
โ”‚  Allowed:                                    โ”‚
โ”‚  { loading: true, data: {...}, error: 'x' }  โ”‚
โ”‚  โ†’ impossible state accepted                 โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  โœ… Discriminated union                      โ”‚
โ”‚                                              โ”‚
โ”‚  type State =                                โ”‚
โ”‚    | { status: 'loading' }                   โ”‚
โ”‚    | { status: 'success'; data: User }       โ”‚
โ”‚    | { status: 'error'; message: string };   โ”‚
โ”‚                                              โ”‚
โ”‚  Impossible states rejected                  โ”‚
โ”‚  โ†’ one status per value                      โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Result Type

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  type Result<T> =                            โ”‚
โ”‚    | { ok: true; value: T }                  โ”‚
โ”‚    | { ok: false; error: string };           โ”‚
โ”‚                                              โ”‚
โ”‚  Result<User>         โ†’ value: User          โ”‚
โ”‚  Result<Product[]>    โ†’ value: Product[]     โ”‚
โ”‚  Result<void>         โ†’ value: void          โ”‚
โ”‚                                              โ”‚
โ”‚  Same shape, any payload                     โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Switch on Discriminant

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  x.kind === 'a'   โ†’   branch A               โ”‚
โ”‚  x.kind === 'b'   โ†’   branch B               โ”‚
โ”‚  x.kind === 'c'   โ†’   branch C               โ”‚
โ”‚                                              โ”‚
โ”‚  Exactly one branch is true at runtime       โ”‚
โ”‚  TypeScript narrows to that branch           โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Filter by Branch

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  const successes = results.filter(           โ”‚
โ”‚    (r): r is { ok: true; value: T } => r.ok  โ”‚
โ”‚  );                                          โ”‚
โ”‚                                              โ”‚
โ”‚  โ†’ successes: { ok: true; value: T }[]       โ”‚
โ”‚                                              โ”‚
โ”‚  Predicate narrows the array element type    โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Discriminated Union Lifecycle

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  1. Construct                                โ”‚
โ”‚                                              โ”‚
โ”‚  const r: Result = { ok: true, value: 'x' }  โ”‚
โ”‚                                              โ”‚
โ”‚  2. Pass around                              โ”‚
โ”‚                                              โ”‚
โ”‚  handle(r)                                   โ”‚
โ”‚                                              โ”‚
โ”‚  3. Narrow                                   โ”‚
โ”‚                                              โ”‚
โ”‚  if (r.ok) { r.value }                       โ”‚
โ”‚                                              โ”‚
โ”‚  4. Exhaust                                โ”‚
โ”‚                                              โ”‚
โ”‚  default: assertNever(r)                     โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: Where the Pattern Fits

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  API results          โ†’ Result<T>            โ”‚
โ”‚  Request state        โ†’ State                โ”‚
โ”‚  Events               โ†’ Event                โ”‚
โ”‚  Redux actions        โ†’ Action               โ”‚
โ”‚  Form state           โ†’ FormState            โ”‚
โ”‚  Command dispatch     โ†’ Command              โ”‚
โ”‚  Auth state           โ†’ Auth                 โ”‚
โ”‚  Route                โ†’ Route                โ”‚
โ”‚  Notifications        โ†’ Notice               โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Visual: When NOT to Use

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Not a discriminated union:                  โ”‚
โ”‚                                              โ”‚
โ”‚  โ€ข Simple union: string | number             โ”‚
โ”‚  โ€ข Independent optional fields               โ”‚
โ”‚  โ€ข Union of primitives                       โ”‚
โ”‚  โ€ข Union of unrelated types                  โ”‚
โ”‚                                              โ”‚
โ”‚  Use a discriminated union when:             โ”‚
โ”‚                                              โ”‚
โ”‚  โ€ข One-of-several object shapes              โ”‚
โ”‚  โ€ข Each shape has distinct fields            โ”‚
โ”‚  โ€ข A literal field identifies the shape      โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ConceptMeaning
Discriminated unionUnion of object types with a literal discriminant
DiscriminantShared literal property distinguishing branches
Narrowingswitch/if on the discriminant narrows
Exhaustivenessnever proves all branches handled
assertNeverHelper that enforces exhaustiveness
Generic DUResult<T>, Event<T>
Impossible statesPrevented by the union shape

Key takeaways:

  • A discriminated union is a union of object types sharing a literal discriminant
  • The discriminant must be a literal type โ€” 'success', true, 1 โ€” not string
  • Narrowing on the discriminant narrows the entire object to that branch
  • Exhaustiveness with never and assertNever proves all branches handled
  • Adding a new variant triggers compile errors in every exhaustive switch
  • Generic discriminated unions (Result<T>) scale to any payload
  • Discriminated unions prevent impossible states โ€” the compiler rejects invalid combinations
  • Common discriminants โ€” kind (shapes), status (state), type (actions), ok (results)
  • Use a factory function to construct branches concisely
  • Prefer a discriminant over in narrowing when you control the type
  • The pattern models results, events, states, commands, actions, API responses โ€” everywhere one-of-several shapes appear
  • Don’t use a discriminated union for simple primitive unions or independent flags

Remember: A discriminated union is TypeScript’s way of saying “this value is exactly one of these shapes, and here’s how to tell them apart.” Pick a literal property, give each branch a distinct value, and the compiler handles the rest โ€” narrowing in conditions, exhaustiveness in switches, and rejecting illegal states at construction. It’s the single most useful pattern in the language for modeling real domains. Once you internalize it, you’ll reach for it every time you catch yourself writing if ('data' in x) or casting between shapes.


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!