After introducing discriminated union types, they showed a way to get closer to pattern matching by havin exhaustiveness checks in default branch of a switch-case
statement.
In documentation there is an implementation of an assertion function, that returns never
both runtime- and type-wise.
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
Intuitively, similar approach could be used in Redux’ reducer
function to make sure each action has been handled:
type Action = TurnOnAction | TurnOffAction;
function reducer(state: State, action: Action) {
switch (action.type) {
case TURN_ON:
return ...
case TURN_OFF:
return ...
default:
assertNever(action);
...
The check seems fair type-wise (action
indeed should have type never
).
Apparently, we don’t exhaustively check all the possible actions, instead we specify a subset of actions we handle in the particular reducer (type Action = ...
).
We don’t care about the other actions (such as internal @@redux/INIT), they just fall through to default case.
But that way we have a runtime problem, because assertNever
throws on every call, so what do we do?
Right, we add a second parameter specifying whether we throw or not:
function assertNever(value: never, noThrow?: boolean): never {
if (noThrow) {
return value
}
throw new Error(...);
}
Fair enough, but, we are about to make a mistake. See, with noThrow === true
signature still says the function returns never
, while it does return a value.
What important about exhaustive checks, is that in most cases assertNever
should be prepended with return
statement so that compiler inters correct return type of a function. This habit and the fact that never
is being omitted from union types, may lead to
function reducer(...): State
switch (action.type) {
case TURN_ON:
return ... // State
case TURN_OFF:
return ... // State
default:
return assertNever(action, true); // never
}
}
And voilà, return type of a function is State | State | never
simplified to State
and we have happy compiler and a runtime error.
Advise if simple: don’t pretend you return never
in the case you return anything. There is unknown
type, which absorbs every type in union, and turned to be a pretty good replacement for never
in this case. That’s how assertNever
could be implemented:
function assertNever(value: never): never;
function assertNever(value: never, noThrow: true): unknown;
function assertNever(value: never, noThrow?: true): never | unknown {
if (!noThrow) {
throw new Error("...");
}
return undefined as never;
}
Or, alternatively, you can separate implementations:
/** Non throwing one */
function assertNever(value: never): unknown {...}
/** The one that throws */
function assertNeverEver(value: never): never {...}
Updated, thanks to @fljot.
It was a mistake to even think about a non-throwing assertNever
. If the non-throwing one is being used only in reducer’s default
branch, then so be it. Let’s just endReducer
:
function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function endReducer<T>(state: T, action: never): T {
return state;
}
The simpler the better:
function reducer(state: State, action: Actions)...
switch (action.type) {
case ...
default:
return endReducer(state, action);
}
}
Updated, thanks to u/AngularBeginner .
Other option is to have throwing and non-throwing functions with the same signature:
function assertNever(x: never): never { throw new Error(`Unxpected value: ${x}`); }
function ensureNever(x: never): void { /* Intentionally empty */ }
Stay safe ♱