A discriminated union is a union of object types that all share one common literal-typed field (the "discriminant" or "tag"), used to narrow the whole object at once instead of checking individual properties:
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string };
type State = LoadingState | SuccessState | ErrorState;
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.join(", "); // narrowed to SuccessState — .data exists here
case "error":
return `Error: ${state.message}`; // narrowed to ErrorState
}
}
This is one of the most valuable patterns in real TypeScript code, because it makes impossible states unrepresentable — there's no way to construct a value that's simultaneously "success" and missing data, unlike a single flat object with an isLoading: boolean and an optional data?: string[] where several fields could disagree with each other. Combined with a switch on the discriminant, TypeScript can also verify exhaustiveness — that every case is handled — using a common trick:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function render2(state: State): string {
switch (state.status) {
case "loading": return "Loading...";
case "success": return state.data.join(", ");
case "error": return `Error: ${state.message}`;
default: return assertNever(state); // compile error if a case was added to State but not handled here
}
}
If someone later adds a fourth variant to State and forgets to handle it above, state in the default branch is no longer narrowed to never, and the call to assertNever fails to compile — catching the omission at build time instead of at runtime.