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:
- A union of object types โ the branches
- A shared property โ the discriminant, present on every branch
- 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'provesris in the branch wherestatusis'success'โ because no other branch has that value. If both branches hadstatus: 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:
neveris 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-
nevertoassertNeverfails
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
assertNeveris 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:inasks “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
Resulttypes are idiomatic: Exceptions in JavaScript can be thrown from anywhere and caught anywhere.Resulttypes 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:
kindis common for shapes,typefor actions,statusfor state,okfor results,tagfor 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
successwithoutdataisn’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
kinddiscriminant picks the branch - Each branch has properties unique to it
unwrapanddescribeexhaustively 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
| Part | Meaning |
|---|---|
| Union of object types | The branches |
| Discriminant property | Shared literal field |
| Literal types | Distinct value per branch |
Common Discriminant Names
| Name | Typical use |
|---|---|
kind | Shapes, variants |
type | Redux actions, events |
status | Request states |
state | Form/state machine |
ok | Result success/failure |
tag | Tagged unions |
Narrowing Tools
| Tool | Narrows |
|---|---|
switch (x.discriminant) | Branch-specific |
if (x.discriminant === 'a') | One branch |
x.discriminant !== 'a' | Everything else |
Exhaustiveness
| Step | Code |
|---|---|
| Helper | function assertNever(x: never): never { throw x; } |
| Switch all cases | case 'a': ... case 'b': ... |
| Default | default: return assertNever(x); |
| Add variant | Compile error in every switch |
Discriminant Types
| Type | Works? |
|---|---|
| String literal | โ |
| Number literal | โ |
| Boolean literal | โ |
| Enum member | โ |
string | โ |
number | โ |
Generic Pattern
| Type | Meaning |
|---|---|
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
| Aspect | Discriminated | in |
|---|---|---|
| Requires literal field | โ | โ |
| Mutually exclusive | โ | โ |
| Exhaustiveness | โ | โ |
| Works on third-party types | โ | โ |
| Illegal states prevented | โ | โ |
Common Patterns
| Pattern | Discriminant |
|---|---|
| Request state | status: 'idle' | 'loading' | 'success' | 'error' |
| Result | ok: true | false |
| Event | kind: 'click' | 'keydown' | 'scroll' |
| Action | type: 'ADD' | 'REMOVE' | 'CLEAR' |
| Shape | kind: 'circle' | 'square' |
Construction
| Form | Code |
|---|---|
| Inline | { kind: 'a', ... } |
| Factory | function a(): A { return { kind: 'a' }; } |
| Const | const A = { kind: 'a' } as const |
Error Patterns
| Error | Cause |
|---|---|
Property 'x' does not exist | Wrong branch for the property |
Type 'string' not assignable to 'never' | Missing exhaustiveness |
Property 'discriminant' missing | Branch without discriminant |
Object literal may only specify known properties | Typo in property name |
When to Use
| Situation | Discriminated 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
| Pitfall | Problem | Solution |
|---|---|---|
string discriminant | No narrowing | Use literals |
| Missing discriminant on a branch | Union doesn’t narrow | Add it to every branch |
| Overlapping literal values | Ambiguous narrowing | Keep values distinct |
| No exhaustiveness check | Missing cases | assertNever in default |
| Illegal state combinations | Bugs | Discriminated union instead of optional flags |
| Typos in discriminant | Compile error or wrong branch | String literals catch typos |
Using as to force a branch | Hidden bugs | Narrow properly |
| Forgetting the discriminant when constructing | Compile error | Include 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
| Concept | Meaning |
|---|---|
| Discriminated union | Union of object types with a literal discriminant |
| Discriminant | Shared literal property distinguishing branches |
| Narrowing | switch/if on the discriminant narrows |
| Exhaustiveness | never proves all branches handled |
assertNever | Helper that enforces exhaustiveness |
| Generic DU | Result<T>, Event<T> |
| Impossible states | Prevented 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โ notstring - Narrowing on the discriminant narrows the entire object to that branch
- Exhaustiveness with
neverandassertNeverproves 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
innarrowing 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!