Exhaustive checks in TypeScript
Forgetful programmer
There are situations where one would like to cover all possible values of a variable using conditional cases. A good example of this situation is a typical reducer function. Let’s consider an example of a counter with simple actions:
1 | type Action = 'increment' | 'decrement'; |
So far so good. Now, let’s see what happens when another kind of action is added – 'reset'
, which in effect should set the counter back to 0.
1 | type Action = 'increment' | 'decrement' | 'reset'; |
Now, let’s assume that you forgot to update the reducer
function. Considered example is overly simplistic, but maybe the codebase is large and you didn’t even know about the existance reducer
at all.
So what would happen if reducer
didn’t handle the 'reset'
case explicitly? It would just fall to the else
branch and return state - 1
which is confusing to say at least.
1 | const initialState = 10; |
Let’s excercise another way of writing the reducer
to make it more future proof for such changes:
1 | function reducer(state: number, action: Action): number { |
It addresses the previous problem, however, it does not type check. reducer
should always return some State
. TypeScript compiler isn’t thorough enough to see that the end of the function is unreachable and requires an explicit return
statement for the else
case. (We’ll get back to this later.)
So let’s tune it a bit to make it work for now:
1 | function reducer(state: number, action: Action): number { |
Now when adding the 'reset'
action, the consequences of forgetting to update the reducerr are arguably less confusing. The function will just return the previous state, without applying any updates.
1 | const initialState = 10; |
But can we do better than that?
State of the art
In some functional languages there is a concept of algebraic data types. ADTs are similar to TypeScript union types, but whenever they are pattern matched against, the compiler will enforce that all possible cases are covered.
Let’s rewrite our problem to PureScript to see ADT in action:
1 | data Action = Increment | Decrement; |
Now, if we add a 'reset'
action, the compiler will kindly ask us to add a case for 'reset'
action in function reducer
.
Can we somehow trick the TypeScript compiler to do the same? It turns out, that there is a way.
Tricking TypeScript to work for us
Going back to our latest attempt:
1 | type Action = 'increment' | 'decrement'; |
If we take a peek at the inferred type of the action
before the final return it should be never
.
1 | function reducer(state: number, action: Action): number { |
This is because our if statements cover all possible action
types and return
in each case. If we add another type of action, eg. 'reset'
TypeScript would infer the type of the action
variable to that unhandled type. Knowing this, we could make the type checking fail if action
is something else than never
. But how?
TypeScript documentation states, that no type is a subtype of, or assignable to, never (except never itself). We can exploit this property and verify if action
can be assigned to a variable of type never
. It will type check correctly only if action
will also be of type never
at that point. In any other case type checking will fail:
1 | function reducer(state: number, action: Action): number { |
Now, when a 'reset'
action is added, type checking of1
const _check: never = action;
will fail, since action of type 'reset'
cannot be assigned to variable of type never
.
Great, problem solved. Still, the solution feels a bit hacky. It’s not obvious what’s going on. Can we do at least a little bit better?
1 | function reducer(state: number, action: Action): number { |
Throwing an error in checkExhausted
is arguable, but this code should actually never be executed in the first place if you care about typing errors. The expression has been turned into a one-liner that also satisfies TypeScript need of a return
statement. We have killed two birds with one line.
Note that this approach also works fine in more complex cases:
1 | type Action = |
Final thoughts
It’s great to have this behaviour available in TypeScript, however, it feels to me a bit like a hack around the language. The compiler already knows that the end of the reducer
function is an unreachable branch of code yet it still demands a return
statement that makes the code less future-proof. I might be missing something here but this behaviour is a bit self-contradictory.
Anyhow, I highly recommend using this trick as it makes the code safer and gives you immediate feedback in case of potential errors.