Lesson 56 of 80
A reducer's switch statement is only safe to write blind if the action type narrows per case — which means the action itself has to be a discriminated union, not a loose object with an optional payload.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A reducer is (state, action) => newState. The dangerous part is always the same: inside the switch (action.type), each case assumes action carries a specific payload — increment has an amount, reset has nothing. If action's type is just { type: string; amount?: number }, TypeScript can't back that assumption up. Every case sees the same loose shape, amount is optional everywhere, and a typo'd action.type string is just a string, not a checked value.
The fix isn't reducer-specific — it's the discriminated union from earlier in this track, applied directly to actions. Give every action variant its own literal type value and its own exact fields, union them together, and switch (action.type) narrows action to the exact variant inside each case, automatically.
type CounterAction =
| { type: "increment"; amount: number }
| { type: "decrement"; amount: number }
| { type: "reset" };
interface CounterState {
count: number;
}
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + action.amount }; // action: { type: "increment"; amount: number }
case "decrement":
return { count: state.count - action.amount };
case "reset":
return { count: 0 }; // action.amount would be a compile error here — "reset" has no amount
default: {
const _exhaustive: never = action;
return state;
}
}
}
Inside case "increment", TypeScript has already ruled out "decrement" and "reset" — action.amount is available and typed number, not number | undefined. Try to read action.amount inside case "reset" and it's a compile error, because that variant genuinely doesn't have one. That's the entire value proposition: the compiler enforces "only read fields that this specific action actually carries," case by case, without you writing a single runtime check.
type field has to be a literal, not string. Writing { type: string; amount: number } for each variant instead of { type: "increment"; amount: number } collapses the whole union into one indistinguishable shape — TypeScript needs the literal to actually discriminate between cases.useReducer's type comes from the reducer function itself, not something you write separately — useReducer(counterReducer, { count: 0 }) infers Dispatch<CounterAction> for the returned dispatch function automatically, because useReducer is generic over the reducer's own parameter types.meta timestamp) still has to be repeated in each union member unless it's factored into a separate intersected type — TypeScript doesn't let you declare "this field is shared across all cases" once at the top of a union the way an interface's shared base class would.This lesson doesn't introduce new mechanics — it's the discriminated-union lesson (the type field as a literal discriminant) and the never/exhaustiveness lesson (the default branch trap) applied together to the single most common place they actually show up in real code: a reducer's switch statement. If either felt abstract on its own, this is what they're for.