Prerequisites
- Union and intersection types ๐
- Type guards and type assertions ๐ก๏ธ
- Basic understanding of enums ๐
What you'll learn
- Create powerful discriminated unions for type safety ๐ฏ
- Implement exhaustive pattern matching ๐๏ธ
- Build type-safe state machines and APIs ๐
- Debug union type issues like a pro โจ
๐ฏ Introduction
Welcome to the fascinating world of discriminated unions! ๐ In this guide, weโll explore one of TypeScriptโs most powerful features for creating type-safe, expressive code that eliminates entire classes of runtime errors.
Youโll discover how discriminated unions can transform complex conditional logic into elegant, bulletproof patterns. Whether youโre building state machines ๐ค, API responses ๐, or complex data structures ๐, discriminated unions provide unmatched type safety and developer experience.
By the end of this tutorial, youโll be crafting sophisticated pattern matching systems that make your code both safer and more readable! Letโs dive in! ๐โโ๏ธ
๐ Understanding Discriminated Unions
๐ค What is a Discriminated Union?
A discriminated union is like a sophisticated traffic light system ๐ฆ. Each โstateโ has a unique identifier (the discriminant) that tells you exactly which type youโre dealing with, plus additional data specific to that state.
In TypeScript terms, a discriminated union combines multiple types where each type has a common property (the discriminant) with a literal value that uniquely identifies it. This means you can:
- โจ Eliminate โimpossible statesโ - no more invalid combinations
- ๐ Get exhaustive checking - TypeScript ensures you handle all cases
- ๐ก๏ธ Achieve perfect type narrowing - access properties with complete safety
๐ก Why Use Discriminated Unions?
Hereโs why developers love discriminated unions:
- Exhaustive Pattern Matching ๐: TypeScript ensures you handle every case
- Impossible States Made Impossible ๐ป: Prevent invalid state combinations
- Self-Documenting Code ๐: The structure tells the story
- Bulletproof Refactoring ๐ง: Add new cases and get compile errors where you need to update
Real-world example: Imagine building an API response system ๐. With discriminated unions, you can represent success, loading, and error states in a way that makes it impossible to accidentally access error messages when youโre in a success state!
๐ง Basic Syntax and Usage
๐ Simple Example
Letโs start with a classic discriminated union:
// ๐ฆ Traffic Light States
type TrafficLight =
| { type: 'red'; timeLeft: number; emoji: '๐ด' }
| { type: 'yellow'; timeLeft: number; emoji: '๐ก' }
| { type: 'green'; timeLeft: number; emoji: '๐ข' };
// ๐ฏ Pattern matching function
function getAction(light: TrafficLight): string {
switch (light.type) { // ๐ TypeScript knows the discriminant!
case 'red':
return `Stop! ${light.emoji} Wait ${light.timeLeft}s`;
case 'yellow':
return `Caution! ${light.emoji} ${light.timeLeft}s left`;
case 'green':
return `Go! ${light.emoji} ${light.timeLeft}s remaining`;
// ๐ No default case needed - all cases handled!
}
}
๐ก Explanation: Notice how type
is our discriminant property! TypeScript automatically narrows the type in each case, giving us perfect autocomplete and type safety.
๐ฏ Common Patterns
Here are the essential discriminated union patterns:
// ๐๏ธ Pattern 1: API Response States
type ApiResponse<T> =
| { status: 'loading'; progress?: number }
| { status: 'success'; data: T; timestamp: Date }
| { status: 'error'; error: string; code: number };
// ๐จ Pattern 2: Form Validation
type ValidationResult =
| { valid: true; value: string }
| { valid: false; errors: string[]; originalValue: string };
// ๐ Pattern 3: State Machine Events
type PlayerAction =
| { type: 'PLAY'; song: string }
| { type: 'PAUSE' }
| { type: 'STOP' }
| { type: 'SKIP'; direction: 'forward' | 'backward' };
๐ก Practical Examples
๐ Example 1: E-commerce Order States
Letโs build a real-world order management system:
// ๐ฆ Order states with different data for each state
type Order =
| {
status: 'pending';
items: string[];
estimatedTotal: number;
customerEmail: string;
}
| {
status: 'confirmed';
orderId: string;
items: string[];
total: number;
paymentMethod: string;
customerEmail: string;
}
| {
status: 'shipped';
orderId: string;
trackingNumber: string;
estimatedDelivery: Date;
carrier: string;
}
| {
status: 'delivered';
orderId: string;
deliveredAt: Date;
signedBy?: string;
}
| {
status: 'cancelled';
orderId?: string;
reason: string;
refundAmount?: number;
};
// ๐ฏ Order processor with exhaustive matching
class OrderProcessor {
processOrder(order: Order): string {
switch (order.status) {
case 'pending':
// ๐ TypeScript knows we have customerEmail and estimatedTotal
return `โณ Order pending for ${order.customerEmail}. Estimated: $${order.estimatedTotal}`;
case 'confirmed':
// ๐ณ TypeScript knows we have orderId and paymentMethod
return `โ
Order ${order.orderId} confirmed! Paid via ${order.paymentMethod}`;
case 'shipped':
// ๐ TypeScript knows we have trackingNumber and carrier
return `๐ Order ${order.orderId} shipped via ${order.carrier}. Tracking: ${order.trackingNumber}`;
case 'delivered':
// ๐ซ TypeScript knows we have deliveredAt
const signInfo = order.signedBy ? ` Signed by: ${order.signedBy}` : '';
return `๐ฆ Order ${order.orderId} delivered on ${order.deliveredAt.toDateString()}.${signInfo}`;
case 'cancelled':
// โ TypeScript knows we have reason and optional refundAmount
const refund = order.refundAmount ? ` Refund: $${order.refundAmount}` : '';
return `โ Order cancelled. Reason: ${order.reason}.${refund}`;
// ๐ No default needed - TypeScript ensures all cases handled!
}
}
// ๐ State transitions with validation
canTransitionTo(order: Order, newStatus: Order['status']): boolean {
switch (order.status) {
case 'pending':
return ['confirmed', 'cancelled'].includes(newStatus);
case 'confirmed':
return ['shipped', 'cancelled'].includes(newStatus);
case 'shipped':
return ['delivered'].includes(newStatus);
case 'delivered':
case 'cancelled':
return false; // ๐ Final states
}
}
}
๐ฏ Try it yourself: Add an โon-holdโ status and update the transition logic!
๐ฎ Example 2: Game Combat System
Letโs create a type-safe combat system:
// โ๏ธ Combat actions with different requirements
type CombatAction =
| { type: 'attack'; weaponId: string; targetId: string; damage: number }
| { type: 'defend'; shieldValue: number; duration: number }
| { type: 'heal'; amount: number; targetId?: string }
| { type: 'cast_spell'; spellId: string; manaCost: number; targets: string[] }
| { type: 'flee'; escapeChance: number; destination?: string };
// ๐ Combat result states
type CombatResult =
| { outcome: 'success'; damage?: number; healing?: number; effect?: string }
| { outcome: 'failure'; reason: string; penaltyApplied?: boolean }
| { outcome: 'critical'; multiplier: number; damage: number; specialEffect: string }
| { outcome: 'blocked'; blockedDamage: number; counterAttack?: boolean };
class CombatEngine {
executeAction(action: CombatAction): CombatResult {
switch (action.type) {
case 'attack':
// ๐ก๏ธ TypeScript knows we have weaponId, targetId, and damage
const isCritical = Math.random() < 0.15;
if (isCritical) {
return {
outcome: 'critical',
multiplier: 2.5,
damage: action.damage * 2.5,
specialEffect: '๐ฅ Critical Strike!'
};
}
return { outcome: 'success', damage: action.damage };
case 'defend':
// ๐ก๏ธ TypeScript knows we have shieldValue and duration
return {
outcome: 'success',
effect: `๐ก๏ธ Defense increased by ${action.shieldValue} for ${action.duration}s`
};
case 'heal':
// ๐ TypeScript knows we have amount and optional targetId
const target = action.targetId ? 'ally' : 'self';
return {
outcome: 'success',
healing: action.amount,
effect: `๐ Healed ${target} for ${action.amount} HP`
};
case 'cast_spell':
// ๐ช TypeScript knows we have spellId, manaCost, and targets array
if (action.targets.length === 0) {
return { outcome: 'failure', reason: 'No targets selected' };
}
return {
outcome: 'success',
effect: `๐ช Cast spell ${action.spellId} on ${action.targets.length} targets`
};
case 'flee':
// ๐ TypeScript knows we have escapeChance and optional destination
const escaped = Math.random() < action.escapeChance;
return escaped
? { outcome: 'success', effect: '๐ Escaped successfully!' }
: { outcome: 'failure', reason: 'Failed to escape', penaltyApplied: true };
}
}
}
๐ Advanced Concepts
๐งโโ๏ธ Generic Discriminated Unions
When youโre ready to level up, try generic discriminated unions:
// ๐ฏ Generic Result type for any operation
type Result<T, E = string> =
| { success: true; data: T; timestamp: Date }
| { success: false; error: E; retryable: boolean };
// ๐ช Using with different data types
type UserResult = Result<{ id: string; name: string; email: string }, 'USER_NOT_FOUND' | 'INVALID_CREDENTIALS'>;
type ApiResult<T> = Result<T, { code: number; message: string; details?: unknown }>;
// ๐ง Generic handler function
function handleResult<T, E>(result: Result<T, E>): string {
if (result.success) {
return `โ
Success! Data received at ${result.timestamp.toISOString()}`;
} else {
const retry = result.retryable ? ' (Retryable)' : ' (Fatal)';
return `โ Error: ${JSON.stringify(result.error)}${retry}`;
}
}
๐๏ธ Nested Discriminated Unions
For the brave developers who need complex state management:
// ๐ Nested discriminated unions for complex workflows
type PaymentMethod =
| { type: 'credit_card'; cardNumber: string; cvv: string; expiryDate: string }
| { type: 'paypal'; email: string; accountId: string }
| { type: 'bank_transfer'; routingNumber: string; accountNumber: string };
type PaymentState =
| {
status: 'selecting_method';
availableMethods: PaymentMethod['type'][];
}
| {
status: 'processing';
method: PaymentMethod;
transactionId: string;
startedAt: Date;
}
| {
status: 'completed';
method: PaymentMethod;
transactionId: string;
completedAt: Date;
receiptUrl: string;
}
| {
status: 'failed';
method: PaymentMethod;
transactionId: string;
error: string;
retryCount: number;
};
// ๐ฏ Exhaustive processing with nested pattern matching
function processPayment(state: PaymentState): string {
switch (state.status) {
case 'selecting_method':
return `๐ Choose from: ${state.availableMethods.join(', ')}`;
case 'processing':
// ๐ We can pattern match on the nested union too!
const methodDesc = (() => {
switch (state.method.type) {
case 'credit_card':
return `๐ณ Card ending in ${state.method.cardNumber.slice(-4)}`;
case 'paypal':
return `๐ฐ PayPal (${state.method.email})`;
case 'bank_transfer':
return `๐ฆ Bank transfer (${state.method.routingNumber})`;
}
})();
return `โณ Processing payment via ${methodDesc} (${state.transactionId})`;
case 'completed':
return `โ
Payment successful! Receipt: ${state.receiptUrl}`;
case 'failed':
const retryMsg = state.retryCount > 0 ? ` (Attempt ${state.retryCount + 1})` : '';
return `โ Payment failed: ${state.error}${retryMsg}`;
}
}
๐ญ Exhaustiveness Checking with never
Ensure compile-time completeness with the never
type:
// ๐ก๏ธ Helper function to ensure exhaustive matching
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
// ๐ฏ Function that MUST handle all cases
function processOrderStatus(order: Order): string {
switch (order.status) {
case 'pending':
return 'Processing...';
case 'confirmed':
return 'Confirmed!';
case 'shipped':
return 'On the way!';
case 'delivered':
return 'Delivered!';
case 'cancelled':
return 'Cancelled';
default:
// ๐จ TypeScript error if we miss any cases!
return assertNever(order);
}
}
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Forgetting the Discriminant
// โ Wrong way - no common discriminant property!
type BadUnion =
| { name: string; age: number }
| { title: string; duration: number };
// TypeScript can't tell these apart! ๐ฐ
function processBadUnion(item: BadUnion) {
// ๐ฅ How do we know which properties are available?
// TypeScript only gives us properties common to ALL types
}
// โ
Correct way - always include a discriminant!
type GoodUnion =
| { type: 'person'; name: string; age: number }
| { type: 'media'; title: string; duration: number };
function processGoodUnion(item: GoodUnion) {
switch (item.type) {
case 'person':
console.log(`${item.name} is ${item.age} years old`); // โ
Perfect!
break;
case 'media':
console.log(`${item.title} lasts ${item.duration} minutes`); // โ
Perfect!
break;
}
}
๐คฏ Pitfall 2: Using Enums as Discriminants
// โ Dangerous - enums can be tricky with discriminated unions
enum Status {
LOADING = 'loading',
SUCCESS = 'success',
ERROR = 'error'
}
type ProblematicResponse =
| { status: Status.LOADING; progress: number }
| { status: Status.SUCCESS; data: string }
| { status: Status.ERROR; error: string };
// โ ๏ธ Works, but string literals are cleaner and more explicit
// โ
Better - use string literal types!
type CleanResponse =
| { status: 'loading'; progress: number }
| { status: 'success'; data: string }
| { status: 'error'; error: string };
// ๐ฏ Much cleaner to work with!
function handleResponse(response: CleanResponse) {
switch (response.status) {
case 'loading':
return `Loading... ${response.progress}%`;
case 'success':
return `Data: ${response.data}`;
case 'error':
return `Error: ${response.error}`;
}
}
๐ฅ Pitfall 3: Missing Exhaustiveness Checking
// โ Dangerous - might miss cases when adding new variants!
type Animal =
| { species: 'dog'; breed: string; good: true }
| { species: 'cat'; indoor: boolean; sass: number }
| { species: 'bird'; canFly: boolean; volume: number };
function describeAnimal(animal: Animal): string {
switch (animal.species) {
case 'dog':
return `๐ Good doggo! Breed: ${animal.breed}`;
case 'cat':
return `๐ฑ Cat with sass level: ${animal.sass}`;
// ๐ฑ Forgot birds! No compile error!
}
// ๐ฅ Runtime error if we get a bird!
}
// โ
Safe - use exhaustiveness checking!
function describeSafeAnimal(animal: Animal): string {
switch (animal.species) {
case 'dog':
return `๐ Good doggo! Breed: ${animal.breed}`;
case 'cat':
return `๐ฑ Cat with sass level: ${animal.sass}`;
case 'bird':
return `๐ฆ Bird volume: ${animal.volume}/10`;
default:
// ๐ก๏ธ This ensures we handle all cases!
const exhaustiveCheck: never = animal;
return exhaustiveCheck;
}
}
๐ ๏ธ Best Practices
- ๐ฏ Always Use a Discriminant: Include a common property with literal types
- ๐ Make Discriminants Meaningful: Use descriptive names like
status
,type
, orkind
- ๐ก๏ธ Enable Exhaustiveness Checking: Use
never
type to catch missing cases - ๐จ Prefer String Literals: Use
'loading'
instead of enums when possible - โจ Keep States Separate: Donโt share properties between different states unless theyโre truly common
- ๐ Make Impossible States Impossible: Design your unions so invalid combinations canโt exist
- ๐งช Add Helpers: Create utility functions for common operations like state transitions
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Media Player State Machine
Create a type-safe media player using discriminated unions:
๐ Requirements:
- ๐ต Player states: idle, loading, playing, paused, error
- ๐ Each state should have appropriate data (current song, progress, error message, etc.)
- ๐ฎ State transition validation
- ๐ Playlist management
- ๐จ Progress tracking and metadata!
๐ Bonus Points:
- Add volume control states
- Implement shuffle/repeat modes
- Create a queue system with different priorities
๐ก Solution
๐ Click to see solution
// ๐ฏ Media player state machine with discriminated unions!
type Song = {
id: string;
title: string;
artist: string;
duration: number;
url: string;
};
type PlayerState =
| { status: 'idle'; lastPlayed?: Song }
| { status: 'loading'; song: Song; progress: number }
| {
status: 'playing';
song: Song;
currentTime: number;
volume: number;
playlistPosition: number;
}
| {
status: 'paused';
song: Song;
pausedAt: number;
volume: number;
playlistPosition: number;
}
| {
status: 'error';
song?: Song;
error: string;
retryCount: number;
};
class MediaPlayer {
private state: PlayerState = { status: 'idle' };
private playlist: Song[] = [];
// ๐ต Get current state description
getStateDescription(): string {
switch (this.state.status) {
case 'idle':
const last = this.state.lastPlayed;
return last ? `๐ค Idle (last: ${last.title})` : '๐ค Ready to play';
case 'loading':
return `โณ Loading ${this.state.song.title} (${this.state.progress}%)`;
case 'playing':
const progress = Math.round((this.state.currentTime / this.state.song.duration) * 100);
return `๐ต Playing: ${this.state.song.title} by ${this.state.song.artist} (${progress}%)`;
case 'paused':
const pauseProgress = Math.round((this.state.pausedAt / this.state.song.duration) * 100);
return `โธ๏ธ Paused: ${this.state.song.title} at ${pauseProgress}%`;
case 'error':
const songInfo = this.state.song ? ` (${this.state.song.title})` : '';
return `โ Error${songInfo}: ${this.state.error}`;
}
}
// ๐ State transitions with validation
canTransition(from: PlayerState['status'], to: PlayerState['status']): boolean {
const transitions: Record<PlayerState['status'], PlayerState['status'][]> = {
idle: ['loading'],
loading: ['playing', 'error', 'idle'],
playing: ['paused', 'loading', 'error', 'idle'],
paused: ['playing', 'loading', 'error', 'idle'],
error: ['loading', 'idle']
};
return transitions[from].includes(to);
}
// โถ๏ธ Play a song
play(song: Song): void {
if (!this.canTransition(this.state.status, 'loading')) {
console.log(`โ Cannot start loading from ${this.state.status} state`);
return;
}
// Start loading
this.state = { status: 'loading', song, progress: 0 };
// Simulate loading
setTimeout(() => {
this.state = {
status: 'playing',
song,
currentTime: 0,
volume: 0.8,
playlistPosition: this.playlist.findIndex(s => s.id === song.id)
};
console.log(`๐ต Now playing: ${song.title}`);
}, 1000);
}
// โธ๏ธ Pause playback
pause(): void {
if (this.state.status === 'playing') {
this.state = {
status: 'paused',
song: this.state.song,
pausedAt: this.state.currentTime,
volume: this.state.volume,
playlistPosition: this.state.playlistPosition
};
console.log(`โธ๏ธ Paused at ${Math.round(this.state.pausedAt)}s`);
}
}
// โถ๏ธ Resume playback
resume(): void {
if (this.state.status === 'paused') {
this.state = {
status: 'playing',
song: this.state.song,
currentTime: this.state.pausedAt,
volume: this.state.volume,
playlistPosition: this.state.playlistPosition
};
console.log(`โถ๏ธ Resumed ${this.state.song.title}`);
}
}
}
// ๐ฎ Test the media player!
const player = new MediaPlayer();
const song: Song = {
id: '1',
title: 'TypeScript Symphony',
artist: 'The Compiler Band',
duration: 240,
url: 'music/typescript-symphony.mp3'
};
console.log(player.getStateDescription()); // ๐ค Ready to play
player.play(song);
console.log(player.getStateDescription()); // โณ Loading...
๐ Key Takeaways
Youโve mastered one of TypeScriptโs most powerful features! Hereโs what you can now do:
- โ Create bulletproof discriminated unions with perfect type safety ๐ช
- โ Implement exhaustive pattern matching that catches all edge cases ๐ก๏ธ
- โ Build type-safe state machines for complex workflows ๐ฏ
- โ Eliminate impossible states from your applications ๐
- โ Design APIs that are impossible to misuse ๐
Remember: Discriminated unions turn runtime errors into compile-time guarantees. Theyโre your secret weapon for building rock-solid applications! ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered discriminated unions and pattern matching!
Hereโs what to do next:
- ๐ป Practice with the media player exercise above
- ๐๏ธ Refactor existing code to use discriminated unions instead of optional properties
- ๐ Move on to our next tutorial: Exhaustiveness Checking: Complete Pattern Coverage
- ๐ Share your type-safe creations with the community!
Remember: Every impossible state you eliminate makes your application more reliable. Keep building bulletproof software! ๐
Happy pattern matching! ๐๐ฏโจ