Discriminated unions are one of TypeScript’s most powerful features for creating type-safe, maintainable code. By providing a way to narrow down types based on specific properties, they help prevent runtime errors and make your code more predictable. Let’s dive deep into how they work and how to use them effectively.
Table of Contents
- What are Discriminated Unions?
- Why Use Discriminated Unions?
- Creating Discriminated Unions
- Type Narrowing with Discriminated Unions
- Best Practices for Discriminated Unions
- Advanced Patterns
- Common Pitfalls and Solutions
- Conclusion
What are Discriminated Unions?
A discriminated union (also known as a tagged union) is a type that uses a common property to differentiate between different possible shapes an object can have. This common property is called the discriminant.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; sideLength: number }
| { kind: 'rectangle'; width: number; height: number };
Code language: JavaScript (javascript)
In this example, kind
is our discriminant property, helping TypeScript determine which shape we’re working with.
Why Use Discriminated Unions?
Discriminated unions provide several key benefits:
- Type Safety: TypeScript can verify at compile-time that you’re handling all possible cases
- Better IDE Support: Get excellent autocomplete and type inference
- Cleaner Code: Avoid complex type casting and type guards
- Maintainable Code: Makes it easier to add new variants in the future
Creating Discriminated Unions
Let’s look at a practical example of creating and using discriminated unions:
// Define our base types
type Success = {
status: 'success';
data: string;
};
type Loading = {
status: 'loading';
};
type Error = {
status: 'error';
message: string;
};
// Combine them into a discriminated union
type RequestState = Success | Loading | Error;
// Function that handles different states
function handleRequestState(state: RequestState): string {
switch (state.status) {
case 'success':
return `Success: ${state.data}`;
case 'loading':
return 'Loading...';
case 'error':
return `Error: ${state.message}`;
}
}
Code language: JavaScript (javascript)
Type Narrowing with Discriminated Unions
One of the most powerful aspects of discriminated unions is automatic type narrowing:
function processShape(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript knows shape is Square here
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript knows shape is Rectangle here
return shape.width * shape.height;
}
}
Code language: JavaScript (javascript)
Best Practices for Discriminated Unions
1. Use Literal Types for Discriminants
type ValidDiscriminant = {
kind: 'valid'; // Literal type
data: string;
};
// Avoid using non-literal types
type InvalidDiscriminant = {
kind: string; // Too broad!
data: string;
};
Code language: JavaScript (javascript)
2. Keep Discriminant Names Consistent
// Good - consistent discriminant name 'type'
type UserEvent =
| { type: 'login'; userId: string }
| { type: 'logout'; userId: string }
| { type: 'register'; email: string };
// Bad - mixing different discriminant names
type UserAction =
| { kind: 'login'; userId: string }
| { type: 'logout'; userId: string }
| { action: 'register'; email: string };
Code language: JavaScript (javascript)
3. Use Exhaustiveness Checking
function assertNever(x: never): never {
throw new Error('Unexpected object: ' + x);
}
function handleShape(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
return assertNever(shape);
}
}
Code language: JavaScript (javascript)
Advanced Patterns
Combining with Generic Types
type Result<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function processResult<T>(result: Result<T>): T | null {
if (result.status === 'success') {
return result.data;
} else {
console.error(result.error);
return null;
}
}
Code language: JavaScript (javascript)
Using with Classes
abstract class Animal {
abstract type: string;
}
class Dog extends Animal {
type = 'dog' as const;
bark() {
return 'Woof!';
}
}
class Cat extends Animal {
type = 'cat' as const;
meow() {
return 'Meow!';
}
}
function makeAnimalSound(animal: Dog | Cat) {
if (animal.type === 'dog') {
return animal.bark();
} else {
return animal.meow();
}
}
Code language: PHP (php)
Common Pitfalls and Solutions
1. Forgetting to Handle All Cases
// TypeScript will error if we don't handle all cases
function incorrectHandler(state: RequestState): string {
if (state.status === 'success') {
return state.data;
}
// Error: Not all code paths return a value
}
Code language: JavaScript (javascript)
2. Using Non-Literal Types for Discriminants
// Bad: Using non-literal types
type BadShape = {
kind: string; // Too broad!
data: any;
};
// Good: Using literal types
type GoodShape = {
kind: 'circle' | 'square';
data: any;
};
Code language: JavaScript (javascript)
Conclusion
Discriminated unions are a fundamental feature of TypeScript that enables you to write more type-safe and maintainable code. They work especially well with pattern matching and exhaustiveness checking, making it easier to handle complex data structures with confidence.
By following the best practices outlined in this guide and understanding common pitfalls, you can effectively use discriminated unions to improve your TypeScript applications. Remember to always use literal types for discriminants, maintain consistent naming, and leverage exhaustiveness checking for maximum type safety.
If you’re interested in learning more about TypeScript’s type system, check out our guide on TypeScript Type Guards for additional type-safety patterns.