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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Action = 'increment' | 'decrement';

function reducer(state: number, action: Action): number {
if (action === 'increment') {
return state + 1;
} else {
return state - 1;
}
}

// Example use case:
const initialState = 1;
const actions = ['increment', 'increment', 'decrement'];
const nextState = actions.reduce(reducer, initialState);
console.log(nextState) // 2;

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
2
3
const initialState = 10;
const nextState = reducer(initialState, 'reset');
console.log(nextState) // 9

Let’s excercise another way of writing the reducer to make it more future proof for such changes:

1
2
3
4
5
6
7
function reducer(state: number, action: Action): number {
if (action === 'increment') {
return state + 1;
} else if (action === 'decrement') {
return state - 1;
}
}

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
2
3
4
5
6
7
8
function reducer(state: number, action: Action): number {
if (action === 'increment') {
return state + 1;
} else if (action === 'decrement') {
return state - 1;
}
return state;
}

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
2
3
const initialState = 10;
const nextState = reducer(initialState, 'reset');
console.log(nextState) // 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
2
3
4
5
6
7
data Action = Increment | Decrement;

reducer :: Int -> Action -> Int
reducer state action =
case action of
Increment -> state + 1;
Decrement -> state - 1;

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
2
3
4
5
6
7
8
9
10
type Action = 'increment' | 'decrement';

function reducer(state: number, action: Action): number {
if (action === 'increment') {
return state + 1;
} else if (action === 'decrement') {
return state - 1;
}
return state;
}

If we take a peek at the inferred type of the action before the final return it should be never.

1
2
3
4
5
6
7
8
9
10
function reducer(state: number, action: Action): number {
if (action === 'increment') {
return state + 1;
} else if (action === 'decrement') {
return state - 1;
}
action; // take a peek in IDE. Its type should be `never` now

return state;
}

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
2
3
4
5
6
7
8
9
10
function reducer(state: number, action: Action): number {
if (action === 'increment') {
return state + 1;
} else if (action === 'decrement') {
return state - 1;
}
const _check: never = action;
// Note that return statement is still required.
return state;
}

Now, when a 'reset' action is added, type checking of

1
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
2
3
4
5
6
7
8
9
10
11
12
function reducer(state: number, action: Action): number {
if (action === 'increment') {
return state + 1;
} else if (action === 'decrement') {
return state - 1;
}
return checkExhausted(action);
}

function checkExhausted(value: never): never {
throw new Error(`Value "${value}" was not checked against.`);
}

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
2
3
4
5
6
7
8
9
10
11
12
type Action =
| { kind: 'increment', value: number }
| { kind: 'decrement', value: number };

function reducer(state: number, action: Action): number {
if (action.kind === 'increment') {
return state + action.value;
} else if (action.kind === 'decrement') {
return state - action.value;
}
return checkExhausted(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.