Discriminated Unions
A discriminated union is a union type where every member has a shared property — the discriminant — with a unique literal value. Checking that property’s value tells TypeScript exactly which member of the union you are working with, narrowing the entire type in one step.
The pattern
Section titled “The pattern”Each member of the union declares the same property name, but each assigns a different literal value to it:
type QuizState = | { status: 'idle' } | { status: 'loading' } | { status: 'active'; question: TriviaQuestion; index: number; total: number } | { status: 'done'; score: number; total: number };status is the discriminant. Every member declares it with a different literal value: 'idle', 'loading', 'active', 'done'. Each 'active' and 'done' member carries additional properties specific to that state.
Narrowing with if
Section titled “Narrowing with if”Check the discriminant and TypeScript narrows to the matching member:
function describe(state: QuizState): string { if (state.status === 'idle') { return 'Ready to start'; // state: { status: 'idle' } } if (state.status === 'active') { return `Question ${state.index + 1} of ${state.total}`; // state: { status: 'active'; question: TriviaQuestion; index: number; total: number } // state.index and state.total are available — they only exist on this branch } if (state.status === 'done') { return `Final score: ${state.score} / ${state.total}`; // state: { status: 'done'; score: number; total: number } } return 'Loading...';}state.index and state.score are only accessible after narrowing to the branch that declares them. Accessing state.score when state.status === 'active' is a compile error — score does not exist on the 'active' member.
Narrowing with switch
Section titled “Narrowing with switch”switch on the discriminant is often cleaner than a chain of if statements:
function render(state: QuizState): void { switch (state.status) { case 'idle': showScreen('start-screen'); break; case 'loading': showScreen('loading-screen'); break; case 'active': showScreen('question-screen'); renderQuestion(state.question, state.index, state.total); // fully typed break; case 'done': showScreen('result-screen'); renderResult(state.score, state.total, false, null); // fully typed break; }}Each case block has state narrowed to the matching member. state.question is only available inside case 'active'. TypeScript enforces that you do not cross-contaminate the branches.
Why this is better than a single object with optional fields
Section titled “Why this is better than a single object with optional fields”The alternative to a discriminated union is a single object with optional properties:
// Avoid this patterninterface QuizState { status: string; question?: TriviaQuestion; index?: number; total?: number; score?: number;}This compiles, but TypeScript cannot tell you that score is absent when status === 'active', or that question is absent when status === 'done'. Every field is T | undefined everywhere — you lose the precision that makes the type useful. The discriminated union makes invalid states unrepresentable.
QuizState in AceIt
Section titled “QuizState in AceIt”AceIt’s types.ts already declares QuizState. Rather than tracking quiz state with a loose object, main.ts can hold a QuizState variable and transition it:
let state: QuizState = { status: 'idle' };
// When the quiz starts loadingstate = { status: 'loading' };
// When questions arrivestate = { status: 'active', question: engine.current!, index: engine.index, total: engine.total };
// When the quiz endsstate = { status: 'done', score: engine.currentScore, total: engine.total };Each assignment is a valid QuizState member. Attempting to set state = { status: 'active', score: 7 } is a compile error — the 'active' member requires question, index, and total, not score.
Exercise
Section titled “Exercise”In types.ts, confirm QuizState is declared as a discriminated union with status as the discriminant.
Then in a scratch file states.ts:
- Write a
function describeState(state: QuizState): stringusing aswitchonstate.statusthat returns a human-readable description for each branch. - In the
'active'case, accessstate.question.question— confirm it is available. - In the
'idle'case, try accessingstate.score— read the error. - Add a
defaultcase — see the next lesson (type predicates andnever) for how to make it exhaustive.
- A discriminated union has a shared property — the discriminant — with a unique literal value in each member.
- Checking the discriminant narrows the entire
statetype to the matching member. - Properties that only exist on one member are only accessible inside that branch.
switchon the discriminant is clean and enables exhaustive checking.- Discriminated unions make invalid states unrepresentable — far safer than a single type with optional fields.