TypeScript Discriminated Unions: A Complete Guide
TypeScript Discriminated Unions: A Complete Guide
TypeScript's type system offers powerful tools for catching bugs before your code ever runs. Among these tools, discriminated unions stand out as one of the most practical patterns for building type-safe applications. Whether you're handling API responses, managing complex state machines, or validating form data, discriminated unions help you model real-world scenarios while letting the compiler do the heavy lifting of catching errors. This guide covers everything you need to know about discriminated unions in modern TypeScript web development, from basic concepts to advanced patterns used in production applications.
What Are Discriminated Unions?
Why Discriminated Unions Matter
Building Your First Discriminated Union
Exhaustive Checking with the Never Type
Common Pitfalls and How to Avoid Them
Best Practices for Clean Discriminated Unions
Performance Considerations
What Are Discriminated Unions?
A discriminated union combines three TypeScript features: union types, type aliases or interfaces, and a shared property called a discriminant. Each member of the union shares this discriminant property, but with a different literal value. When TypeScript sees you check the discriminant, it narrows down which specific union member you're working with, giving you precise type information within that code path. This pattern is fundamental to type-safe JavaScript and TypeScript development, enabling developers to write code that the compiler can verify at compile time.
The pattern works because the discriminant property uses literal types rather than general string or number types. This specificity lets TypeScript understand exactly which shape your data takes at any given point in your code. The result is code that's both safer and easier to understand, because impossible states become unrepresentable and type assertions become unnecessary. Understanding discriminated unions is essential for any developer working with modern JavaScript frameworks or building complex applications with advanced TypeScript patterns.
Why Discriminated Unions Matter
Without discriminated unions, developers typically use optional properties to represent different states. Consider a common pattern for API responses: an interface with optional data and error properties. This approach seems convenient but creates problems. You can end up with both data and error set simultaneously, or neither set when they should be mutually exclusive. TypeScript can't help you catch these invalid states because the type system doesn't know which properties belong together.
Discriminated unions solve this by making each state a distinct type. Loading, success, and error states become separate interfaces, each with their own relevant properties. The shared discriminant property acts as a tag that tells TypeScript which state you're dealing with. This approach eliminates entire classes of runtime errors by making invalid states impossible to represent in your type system. When building robust web applications, this level of type safety significantly reduces debugging time and improves code maintainability.
Building Your First Discriminated Union
Let's walk through creating a discriminated union for handling API responses. We'll define each state as a separate interface, combine them into a union type, and then see how TypeScript automatically narrows types based on discriminant checks.
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: User[];
}
interface ErrorState {
status: 'error';
message: string;
code: number;
}
type ApiResponse = LoadingState | SuccessState | ErrorState;
function handleApiResponse(response: ApiResponse) {
if (response.status === 'loading') {
// TypeScript knows: response is LoadingState
return showSpinner();
}
if (response.status === 'error') {
// TypeScript knows: response is ErrorState
return showError(response.message);
}
// TypeScript knows: response must be SuccessState
return renderUsers(response.data);
}
Discriminated unions shine in several common web development scenarios. Here are the most impactful use cases where this pattern delivers the greatest value in professional TypeScript development.
API Response Handling: The most common use case for discriminated unions is handling API responses. Instead of checking for optional properties, you define explicit states that represent loading, success, and failure scenarios. This pattern scales well as your application grows and you need to handle additional response states like pagination or partial success.
State Machines: Discriminated unions excel at modeling state machines where each state has specific data and valid transitions. A file upload flow, for example, moves through states like idle, uploading, uploaded, and failed. Each state carries exactly the data it needs—no more, no less. TypeScript ensures you handle every state correctly and prevents impossible combinations.
Form Validation: Form validation often involves tracking multiple error states or validation stages. Discriminated unions let you model these states explicitly, with each state containing only the relevant validation data. Whether the form is pristine, validating, valid, or has specific field errors, the type system guides your implementation.
Event Systems: Event-driven architectures benefit from discriminated unions when different event types carry different payloads. The event type acts as the discriminant, allowing handlers to access the correct payload shape based on the event type they've subscribed to.
Exhaustive Checking with the Never Type
One of the most powerful patterns with discriminated unions is exhaustive checking. TypeScript's never type represents values that can never occur. By combining this with discriminated unions, you can ensure you've handled every possible state in your code. This technique is invaluable for maintaining code quality in large applications.
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
function handleApiResponse(response: ApiResponse): JSX.Element {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={response.data} />;
case 'error':
return <ErrorDisplay message={response.message} />;
default:
return assertNever(response);
}
}
If you add a new state to ApiResponse but forget to handle it, TypeScript will error on the assertNever(response) line. The default branch expects never, but with a new unhandled state, response won't be assignable to never—the compiler will tell you exactly what case you're missing. This pattern serves as a safety net that ensures exhaustive checking works correctly. When you extend your union types, the compiler ensures your code handles all cases, preventing runtime errors from unhandled states.
Common Pitfalls and How to Avoid Them
Destructuring Breaks Type Narrowing: A frequent mistake is destructuring the discriminant property before checking it. TypeScript can't track type refinements through destructured variables in the same way it can through property access. Always check the discriminant property directly on the object, then destructure inside the narrowed branch.
Using Non-Literal Discriminant Types: Your discriminant must be a literal type, not a general string or number. Without literal types, TypeScript can't narrow the union because it doesn't know which specific values map to which union members. Use 'loading' | 'success' | 'error' not string.
// This doesn't work as expected
function handleResponse(response: ApiResponse) {
const { status } = response; // Destructured too early
if (status === 'success') {
// ERROR: TypeScript doesn't know response.data exists
return response.data;
}
}
// Do this instead
function handleResponse(response: ApiResponse) {
if (response.status === 'success') { // Check on original object
const { data } = response; // Now destructuring is safe
return data;
}
}
Best Practices for Clean Discriminated Unions
Choose Consistent Discriminant Names: Pick a discriminant property name and use it consistently throughout your codebase. Common choices include type, kind, status, or variant. Consistency helps developers recognize the pattern immediately and makes your codebase more maintainable.
Prefer String Literals Over Numbers: String literals make your code more readable and easier to debug. When you see status === 'loading' in your code, it's immediately clear what you're checking. Number codes like status === 0 require mental translation and are more error-prone.
Keep States Mutually Exclusive: Each state should be well-defined and mutually exclusive. Don't overlap properties between states. If multiple states share common properties, consider extracting a base interface that all states extend from. This principle aligns with best practices for writing maintainable TypeScript code.
interface BaseState {
requestId: string;
timestamp: Date;
}
interface LoadingState extends BaseState {
status: 'loading';
}
interface SuccessState extends BaseState {
status: 'success';
data: User[];
}
Document Complex States: When a state has many properties or complex validation rules, add TypeScript comments or JSDoc to explain what each property means. This documentation helps future maintainers understand the type system you've built. Following these patterns will help you write clean, maintainable discriminated unions in your production code.
Performance Considerations
Discriminated unions have minimal runtime performance impact because they're a compile-time feature. TypeScript strips all type information before generating JavaScript, so your runtime code is just plain object handling with simple property checks. The pattern actually improves performance in some cases by eliminating the need for runtime type checks or instanceof calls. Once compiled, your discriminated union code often runs faster than equivalent runtime-validated code because the type narrowing has already been done at compile time. This makes discriminated unions an excellent choice for high-performance web applications built with TypeScript where type safety is critical without sacrificing speed. Pairing this pattern with other advanced JavaScript array methods can create powerful, type-safe data processing pipelines.
Discriminated unions represent one of TypeScript's most practical features for building reliable applications. By modeling your domain with explicit, mutually exclusive states and letting the compiler handle type narrowing, you eliminate entire categories of bugs before they can occur. The exhaustive checking pattern provides an additional safety net that grows more valuable as your codebase evolves. Start by identifying places in your codebase where optional properties create ambiguous states. Refactor these to use discriminated unions, and you'll find your code becomes more explicit, more maintainable, and safer. The initial investment in learning the pattern pays dividends every time the compiler catches a bug you would have spent hours debugging. For teams building modern web applications with TypeScript, mastering discriminated unions is essential for maintaining code quality and reducing runtime errors.