Note on TypeScript's exhaustive type checks in scope of Redux's reducer


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 ♱