Nick Lawler Website

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:

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:

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

function toGermanExhaustive(x: NoYes): string {
  if (x === NoYes.No) {
    return 'Nein';
  }
    
  x satisfies NoYes.Yes
  return 'Ja';
}