satisfies never off by one
We look at a new way to implement exhaustiveness checking in TypeScript. Unlike other entries in the genre it works with if statements and ternary expressions, while leaving no unnecessary conditions or unreachable code. Skip to the summary here or read along for examples.
The problem
I define exhaustiveness checking as: âI get an informative type error when I add a new possible value to a union typeâ. Itâs a way to avoid bugs caused by forgetting to handle new cases.
I assume you know a bit about the ways to implement it in TypeScript, and also have a sense of when and when not to seek it out.
Letâs start with an example stolen from Dr. Axel Rauschmayerâs article on the topic:
function toGermanExhaustive(x: NoYes): string {
if (x === NoYes.No) {
return 'Nein';
}
if (x === NoYes.Yes) {
return 'Ja';
}
}
This function is exhaustive, but it doesnât implement exhaustiveness checking. Moreover it doesnât even pass type checking. TS doesnât know that you canât go past the second return, so you get a type error complaining that the implicit final return value undefined doesnât match the annotated return type string.
typescript-eslint however does know that you canât go past the second return, sort of. If you have no-unnecessary-conditions turned on it will correctly call out that if (x === NoYes.Yes) is redundant.
There are a few fine ways to write this logic with type checking and exhaustiveness checking:
- use a
switchstatement instead. TS andtypescript-eslintboth nudge towards exhaustiveness better withswitch. - Use an object
as constas a lookup table.
I would probably reach for a lookup table object in this example. But letâs pretend it had to stay an if, e.g. you want to leverage other narrowing conditions like !==.
Exhaustive if
First, letâs ditch the unnecessary if.
function toGermanExhaustive(x: NoYes): string {
if (x === NoYes.No) {
return 'Nein';
}
return 'Ja';
}
This passes TS, but doesnât have exhaustiveness checking. Letâs apply the satisfies never trick to try and implement it.
function toGermanExhaustive(x: NoYes): string {
if (x === NoYes.No) {
return 'Nein';
}
return 'Ja';
x satisfies never;
}
But this doesnât work. Because we no longer branch, we no longer narrow x, therefore the satisfies check fails.
TS also complains that the code after the return is unreachable.
Here we should pause to get our bearings. Itâs helpful to map out the type of x before and after each condition. Helpfully, we can use the satisfies operator here to check our work.
function toGermanExhaustive(x: NoYes): string {
x satisfies NoYes.No | NoYes.Yes
if (x === NoYes.No) {
x satisfies NoYes.No
return 'Nein';
}
x satisfies NoYes.Yes
return 'Ja';
x satisfies NoYes.Yes;
}
Now an experiment: what happens if we add a value to NoYes?
enum NoYes {
No,
Yes,
Probs // new
}
function toGermanExhaustive(x: NoYes): string {
x satisfies NoYes.No | NoYes.Yes; // error
if (x === NoYes.No) {
x satisfies NoYes.No;
return 'Nein';
}
x satisfies NoYes.Yes; // error
return 'Ja';
x satisfies NoYes.Yes; // error
}
Every satisfies besides the one inside the if block lit up, because thereâs now a value of x that doesnât fit. Somehow just by spamming satisfies we got an exhaustiveness check. Why?
Because the never in satisfies never isnât special or required. As long as the x in x satisfies Type contains a value incompatible with Type, we get an error pointing out which case we need to handle.
Thereâs a problem though: we want one exhaustiveness check but this function now has two. Which one should we keep?
I like the one âoff by oneâ from where we thought the satisfies never would go: the one between the last if and the final return.
function toGermanExhaustive(x: NoYes): string {
if (x === NoYes.No) {
return 'Nein';
}
x satisfies NoYes.Yes
return 'Ja'
}
I like it for a few reasons:
- Only have to write down one enum value out of potentially many.
- The value you write down describes the variableâs value. You get to read it almost as if it were an equality check, but with no runtime cost.
But there is some flexibility here. If the last N cases really are all handled the same, we can check that weâre âoff by Nâ before the final return.
function isAffirmative(x: NoYes): boolean {
if (x === NoYes.Yes) {
return true;
}
x satisfies NoYes.No | NoYes.Probs
return false;
}
Exhaustive ternary
Imagine our example function using a ternary expression instead of an if.
function toGermanExhaustive(x: NoYes): string {
return x === NoYes.No
? 'Nein'
: 'Ja';
}
To check the value âoff by oneâ, we have to somehow sneak a satisfies expression into the last arm of a ternary chain. We can do that with the JS comma operator.
function toGermanExhaustive(x: NoYes): string {
return x === NoYes.No
? 'Nein'
: (x satisfies NoYes.Yes, 'Ja');
}
tldr
- First decide if and when you should implement exhaustiveness checking in your code.
- Second, consider implementing exhaustiveness checks not by checking that a variable
satisfies never, but that itsatisfiesexactly one possible value.
function toGermanExhaustive(x: NoYes): string {
if (x === NoYes.No) {
return 'Nein';
}
x satisfies NoYes.Yes
return 'Ja';
}