Type a reducer so that an unhandled action fails the build
A store reducer switches on an Action union. Today a new action type silently falls into default, returns the old state, and the bug surfaces only in the UI.
Constraints: the failure must be a compile error at every reducer that misses the new action, the check must be reusable across many reducers rather than copy-pasted, and the default branch must still throw at runtime if a bad action ever reaches it.
type Action =
| { type: 'inc'; by: number }
| { type: 'dec'; by: number }
| { type: 'reset' };
interface State { count: number }
function reducer(state: State, action: Action): State {
switch (action.type) {
// your code here
}
}
Write the implementation.
Switch on the literal type field and end with default: return assertNever(action), where assertNever(value: never): never throws. Every handled case narrows its member away, so a complete union leaves never and a new action fails the build.
- ✗Typing the
assertNeverparameter asunknownorany, which accepts every action - ✗Returning the old state from
default, which compiles happily forever - ✗Widening the union with a
stringtypefield, so nothing ever narrows tonever
- →Why must the
assertNeverparameter be typedneverrather thanunknown? - →How does this interact with an action union assembled from many feature slices?
Solution
The key is a helper whose parameter is typed never. Only something that cannot exist is assignable to it, so the call compiles exactly while the union is fully handled.
type Action =
| { type: 'inc'; by: number }
| { type: 'dec'; by: number }
| { type: 'reset' };
interface State { count: number }
function assertNever(value: never): never { // reused by every reducer
throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'inc': return { count: state.count + action.by };
case 'dec': return { count: state.count - action.by };
case 'reset': return { count: 0 };
default: return assertNever(action); // ✅ action has narrowed to never
}
}
Add { type: 'setTo'; value: number } and every reducer that misses it fails to build:
// error TS2345: Argument of type '{ type: "setTo"; value: number; }'
// is not assignable to parameter of type 'never'.
⚠️ The parameter must be never. With unknown or any every action fits, the call always compiles, and the whole check collapses into a runtime throw. The default branch still earns its keep as a backstop: if untyped JavaScript dispatches an unknown action, it throws.