
Discriminated Unions: Model Complex State with TypeScript
Managing isLoading, isError, and data separately creates impossible states. Discriminated unions make illegal states unrepresentable.

Managing isLoading, isError, and data separately creates impossible states. Discriminated unions make illegal states unrepresentable.
Put everything in one `UserContext`? Bad move. Learn how a single update re-renders the entire app and how to optimize using Context Splitting and Selectors.

Stop using 'any'. How to build reusable, type-safe components using Generics. Advanced patterns with extends, keyof, and infer.

When you break a monolith into microservices, you lose ACID transactions. How do you ensure data consistency across boundaries? We explore the limitations of Two-Phase Commit (2PC) and dive deep into the Saga Pattern, Event Consistency, and practical implementation strategies like Choreography vs Orchestration.

Fixing the crash caused by props coming through as undefined from the parent component.

I was building a feature to fetch data from an API and display it on screen. At first, it seemed simple. Just three pieces of state: isLoading, isError, and data.
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState<User | null>(null);
// API call
setIsLoading(true);
try {
const result = await fetchUser();
setData(result);
} catch (error) {
setIsError(true);
} finally {
setIsLoading(false);
}
But something felt off. While writing the rendering logic, I realized that isLoading could be true while data also exists. Same with isError being true while we have successful data. These state combinations don't make sense. If we're loading, there shouldn't be data yet. If there's an error, there shouldn't be successful data.
The bigger problem? The type system couldn't prevent these impossible states. Managing three independent pieces of state meant that combinations that should never exist were completely legal according to TypeScript.
Think about a traffic light. Just because there are three lights—red, yellow, and green—doesn't mean we should model them as isRed, isYellow, and isGreen booleans. That would make it possible for red and green to be on simultaneously. A real traffic light can only be in one state at a time.
I realized the problem wasn't just bugs in my logic—it was how I was modeling state itself. An API request's state isn't a combination of multiple booleans. It's one distinct state at any given time. That's exactly what discriminated unions are for.
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
const [state, setState] = useState<ApiState<User>>({ status: 'idle' });
Everything changed at this moment. Now the state is exactly one of four possibilities: idle (haven't requested yet), loading (in progress), success (completed with data), or error (failed with error message). Impossible combinations simply can't be represented in the type system.
The status property is the key. It's called a discriminant. TypeScript uses this property to determine exactly which state we're in and narrows the type accordingly.
function renderUser(state: ApiState<User>) {
switch (state.status) {
case 'idle':
return <div>Press button to load</div>;
case 'loading':
return <div>Loading...</div>;
case 'success':
// state.data is automatically inferred as User type here
return <div>Hello, {state.data.name}</div>;
case 'error':
// state.error is automatically inferred as string type here
return <div>Error: {state.error}</div>;
}
}
When we check state.status in a switch statement, TypeScript knows the exact type within each case block. In the success case, we can access state.data. In the error case, we can access state.error. Try to access these properties in the wrong case? Compile error.
This is the "Make Illegal States Unrepresentable" principle in action. Use the type system to make nonsensical states impossible to construct in the first place.
Discriminated unions shine when building multi-step signup forms.
type SignupStep =
| { step: 'email'; email: string }
| { step: 'password'; email: string; password: string }
| { step: 'profile'; email: string; password: string; name: string }
| { step: 'complete'; userId: string };
function SignupForm({ currentStep }: { currentStep: SignupStep }) {
switch (currentStep.step) {
case 'email':
return <EmailInput defaultValue={currentStep.email} />;
case 'password':
return <PasswordInput email={currentStep.email} />;
case 'profile':
return <ProfileForm name={currentStep.name} />;
case 'complete':
return <WelcomeMessage userId={currentStep.userId} />;
}
}
Each step's available data is clearly expressed in the type. Try to access password in the email step? Compile error.
Login state is another perfect use case. I've been burned before trying to manage this with a single isLoggedIn boolean.
type AuthState =
| { kind: 'anonymous' }
| { kind: 'authenticating' }
| { kind: 'authenticated'; user: User; token: string }
| { kind: 'authFailed'; reason: string };
function getAuthHeader(auth: AuthState): string | null {
if (auth.kind === 'authenticated') {
// TypeScript guarantees auth.token exists here
return `Bearer ${auth.token}`;
}
return null;
}
Type narrowing works with if statements too. Once we check auth.kind === 'authenticated', TypeScript narrows auth to { kind: 'authenticated'; user: User; token: string } within that block.
Defining Redux actions as discriminated unions gives you complete type safety in reducers.
type TodoAction =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'DELETE_TODO'; id: number }
| { type: 'SET_FILTER'; filter: 'all' | 'active' | 'completed' };
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
// action.text is automatically inferred as string
return { ...state, todos: [...state.todos, { text: action.text, completed: false }] };
case 'TOGGLE_TODO':
// action.id is automatically inferred as number
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case 'DELETE_TODO':
return { ...state, todos: state.todos.filter(todo => todo.id !== action.id) };
case 'SET_FILTER':
return { ...state, filter: action.filter };
}
}
Each action type has different payload requirements. With discriminated unions, TypeScript knows exactly which properties are available based on the type.
Another powerful feature of discriminated unions is exhaustiveness checking—verifying you've handled all cases.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'rectangle'; width: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.size ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// shape is type never here
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
If someone later adds a triangle type, you'll get a compile error in the default case. shape is no longer type never because there's an unhandled case. This ensures that when new cases are added, you'll immediately discover any places you forgot to update.
You could use TypeScript enums, but I prefer discriminated unions.
// Enum approach
enum Status {
Idle,
Loading,
Success,
Error,
}
interface ApiStateWithEnum<T> {
status: Status;
data?: T;
error?: string;
}
// Discriminated union approach
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
The enum approach brings us back to the impossible states problem. data and error are optional, so status could be Success while data is undefined. Discriminated unions encode exactly what data exists in each state.
Plus, discriminated unions use string literals, making runtime debugging easier. Enums compile to numbers, which are cryptic in logs.
You can migrate existing code to discriminated unions incrementally.
// Before: multiple booleans
interface UserProfileBefore {
isLoading: boolean;
isError: boolean;
isSaving: boolean;
profile: UserProfile | null;
error: string | null;
}
// After: discriminated union
type UserProfileState =
| { status: 'loading' }
| { status: 'loaded'; profile: UserProfile }
| { status: 'saving'; profile: UserProfile }
| { status: 'error'; error: string };
// Adapter for migration
function adaptLegacyState(legacy: UserProfileBefore): UserProfileState {
if (legacy.isLoading) return { status: 'loading' };
if (legacy.isError) return { status: 'error', error: legacy.error! };
if (legacy.isSaving) return { status: 'saving', profile: legacy.profile! };
return { status: 'loaded', profile: legacy.profile! };
}
If you can't refactor everything at once, create an adapter function for gradual migration.
Since adopting discriminated unions, my entire approach to state design has changed. Instead of combining multiple flags to represent state, I model state as one complete value.
A traffic light isn't three booleans for red, yellow, and green. It's one value: "the current signal state." API requests are the same. Not a combination of isLoading and data, but one value: "the current request state."
The type system isn't just a bug-catching tool. It's a thinking tool that shapes how we model our domain. Discriminated unions are a powerful example of this. When you make illegal states unrepresentable at the type level, you don't need runtime code to defend against those situations. The compiler does it for you.
Now whenever I face complex state, I ask myself: "Can I enumerate all possible shapes of this state?" If the answer is yes, that's the signal to reach for discriminated unions.