Skip to content

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.

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.

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.

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 pattern
interface 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.

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 loading
state = { status: 'loading' };
// When questions arrive
state = { status: 'active', question: engine.current!, index: engine.index, total: engine.total };
// When the quiz ends
state = { 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.

In types.ts, confirm QuizState is declared as a discriminated union with status as the discriminant.

Then in a scratch file states.ts:

  1. Write a function describeState(state: QuizState): string using a switch on state.status that returns a human-readable description for each branch.
  2. In the 'active' case, access state.question.question — confirm it is available.
  3. In the 'idle' case, try accessing state.score — read the error.
  4. Add a default case — see the next lesson (type predicates and never) 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 state type to the matching member.
  • Properties that only exist on one member are only accessible inside that branch.
  • switch on the discriminant is clean and enables exhaustive checking.
  • Discriminated unions make invalid states unrepresentable — far safer than a single type with optional fields.