Eliminating Impossible States with TypeScript Discriminated Unions
Stop relying on conflicting boolean flags. Learn how to use TypeScript Discriminated Unions to eliminate impossible states and implement exhaustive type checking.
15 Jul 2025, 05:08 UTC

The Problem: The Boolean Flag Trap
Many developers manage application state using a collection of optional booleans. Consider a typical data-fetching state:
interface State {
isLoading: boolean;
isError: boolean;
data?: string[];
}
This structure allows for "impossible states." For example, isLoading and isError could both be true simultaneously, or both could be false while data is undefined. The developer must write complex conditional logic to handle these contradictions, and the compiler cannot help because the types don't reflect the actual business logic.
The Solution: Discriminated Unions
A discriminated union (also known as a tagged union) uses a single literal property—the discriminant—to distinguish between different object shapes in a union. Instead of multiple booleans, you define a set of distinct types that share a common property with a specific literal value.
When you check the value of this discriminant in a conditional block, TypeScript performs type narrowing. This means the compiler automatically knows exactly which properties are available within that block, removing the need for unsafe type casting or any.
Practical Implementation: Type-Safe State
To implement this, define each possible state as its own interface and combine them into a union type. In this example, we assume TypeScript 4.0+ for standard narrowing behavior.
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function renderState(state: RequestState) {
switch (state.status) {
case 'idle':
return 'Waiting to start...';
case 'loading':
return 'Loading data...';
case 'success':
// TypeScript knows 'data' exists here
return `Loaded ${state.data.length} items`;
case 'error':
// TypeScript knows 'message' exists here
return `Error: ${state.message}`;
default:
// Exhaustiveness check
const _exhaustiveCheck: never = state;
return _exhaustiveCheck;
}
}
How the Exhaustiveness Check Works
The default case uses the never type. The never type represents a value that should not exist. If you add a new state to RequestState (e.g., { status: 'recovering' }) but forget to add a case for it in the switch statement, TypeScript will throw a compiler error because the recovering state cannot be assigned to never. This forces the developer to handle every possible state explicitly.
Trade-offs and Limitations
While powerful, discriminated unions have specific constraints:
- Literal Requirement: The discriminant must be a literal type (a specific string, number, or boolean). Using a generic
stringtype will not trigger narrowing. - Cognitive Load: In extremely large applications, deeply nesting unions within other unions can make type definitions difficult to read.
- Boilerplate: You must explicitly define the discriminant property in every member of the union.
Verification and Testing
To verify your implementation is working correctly, attempt to access a state-specific property outside of its guard. For example, trying to access state.data directly on a variable of type RequestState (without a switch or if check) should trigger a TS2339 error: Property 'data' does not exist on type 'RequestState'.
Run the TypeScript compiler (tsc) to ensure that all paths in your state handlers are covered and that the never assignment remains valid.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.