In modern frontend and backend development with TypeScript, ensuring type safety during state transitions is a crucial element in building robust and crash-free applications. When handling asynchronous API communications (such as transitioning between loading, success, and error states) or complex user interactions, ambiguous type definitions can easily lead to unexpected runtime errors.
In this article, we will explore how to harness the power of Discriminated Unions (also known as Tagged Unions) in TypeScript to manage state safely, elegantly, and with minimum boilerplate.
1. What are Discriminated Unions?
A Discriminated Union is a pattern in TypeScript that combines multiple distinct object types into a single union type (|), where each object type shares a common literal property—referred to as the “discriminant” or “tag”.
TypeScript’s compiler can read this tag inside conditional structures (like if or switch statements) to automatically narrow down the object’s type (known as Type Narrowing) within the corresponding block.
Key Ingredients of a Discriminated Union:
- Multiple object types combined into a union.
- A shared property name (the discriminant) present in every type.
- The type of this shared property is a literal type (a specific string, number, or boolean).
2. The Naive Approach: Single Monolithic Objects
Before diving into Discriminated Unions, let’s examine an anti-pattern: using a single object representation containing all optional fields.
// Anti-pattern: Optional properties for all possible states
interface FetchState<T> {
isLoading: boolean;
data?: T;
error?: Error;
}
function renderState<T>(state: FetchState<T>) {
if (state.isLoading) {
return "Loading...";
}
// Even if isLoading is false, there is no guarantee that data or error is present.
if (state.data) {
// TypeScript requires a check or safe-navigation because 'data' is optional.
return `Data: ${JSON.stringify(state.data)}`;
}
if (state.error) {
return `Error: ${state.error.message}`;
}
return "Unknown state";
}
Disadvantages of this approach:
- We can easily configure impossible states (e.g., both
dataanderrorbeing defined at the same time, or both beingundefinedwhenisLoadingis false). - Developers must constantly use optional chaining (
?.) or non-null assertion operators (!) to access data, which undermines the purpose of static type checking.
3. Implementing Discriminated Unions
Let’s redesign our state representation using a set of strictly defined states, combined via a union type.
// Define separate interfaces for every distinct status
interface IdleState {
type: 'idle';
}
interface LoadingState {
type: 'loading';
}
interface SuccessState<T> {
type: 'success';
data: T;
}
interface ErrorState {
type: 'error';
error: Error;
}
// Combine them into a union type
type FetchState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;
In this model, the type property is our discriminant. Let’s see how easy it is to render this state now:
function renderState<T>(state: FetchState<T>): string {
switch (state.type) {
case 'idle':
return 'Waiting...';
case 'loading':
return 'Loading...';
case 'success':
// The compiler guarantees that 'data' is present here. No optional chaining needed.
return `Data: ${JSON.stringify(state.data)}`;
case 'error':
// The compiler guarantees that 'error' is present here.
return `Error occurred: ${state.error.message}`;
}
}
4. Safety First: Exhaustiveness Checking
One of the greatest benefits of using Discriminated Unions is the ability to leverage Exhaustiveness Checking. When you add a new state type down the road, you want the compiler to warn you if you forgot to handle it.
We can achieve this using the never type:
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function renderStateWithGuard<T>(state: FetchState<T>): string {
switch (state.type) {
case 'idle':
return 'Waiting...';
case 'loading':
return 'Loading...';
case 'success':
return `Data: ${state.data}`;
case 'error':
return `Error: ${state.error.message}`;
default:
// If we add 'canceling' state in the future and forget to add a case for it,
// TypeScript will raise a compile-time error here because 'state' cannot be reduced to 'never'.
return assertNever(state);
}
}
Conclusion
By adopting Discriminated Unions in your codebase, you get:
- Zero Impossible States: Impossible variations of states are structurally prohibited.
- Predictable Data Access: Eliminates defensive programming patterns (like non-null assertion overrides).
- Compile-Time Peace of Mind: Exhaustiveness checking acts as a built-in safety net during refactoring.
Whether you are designing React reducer actions, Redux states, or API message payloads, Discriminated Unions are a core TypeScript pattern that you should embrace.
