Discriminated Union
A Discriminated Union (also called a tagged union) is the most powerful pattern in TypeScript for modeling complex, mutually exclusive states.
The mental model
Imagine you are fetching data. The state is either loading, success (with data), or error (with a message).
A naive approach puts everything in one object with optional fields:
// ❌ Bad pattern
interface State {
status: "loading" | "success" | "error";
data?: string[];
errorMsg?: string;
}
This is dangerous because nothing prevents an impossible state like { status: "loading", data: ["a"] }.
A discriminated union solves this by creating distinct interfaces that share one common literal property (the discriminant).
// Good pattern
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; errorMsg: string };
type AppState = LoadingState | SuccessState | ErrorState;
Narrowing with the Discriminant
Because status is a literal string unique to each type, TypeScript can use a simple if or switch statement on the status property to perfectly narrow the type.
function renderUI(state: AppState) {
if (state.status === "loading") {
// state is strictly LoadingState. state.data does not exist.
return "Loading...";
}
if (state.status === "success") {
// state is strictly SuccessState. We can safely access state.data.
return `Loaded ${state.data.length} items`;
}
if (state.status === "error") {
// state is strictly ErrorState.
return `Error: ${state.errorMsg}`;
}
}
Redux Actions are Discriminated Unions
If you've used Redux, you've used this pattern. Every action has a type property (the discriminant) and an optional payload. The reducer uses a switch(action.type) statement to narrow the action down so it can safely read the specific payload.
Use Discriminated Unions to make impossible states impossible to represent. Create distinct interfaces for distinct states and link them with a shared literal property.