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 intuition and examples.

The problem

I define exhaustiveness checking as: “I get an informative type error when I add new possibilities 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.

To show why exhaustiveness checking doesn't typically play so well with if let’s look at an example 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. So if is doubly messed up here.

To improve this, we can easily use a switch statement instead, or use an object as const as a lookup table. But let’s pretend it had to stay an if, because e.g. you want to leverage other narrowing operators 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; // unreachable, missing NoYes.Yes
}

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. We can use the satisfies operator itself here to map out the type of x before and after each condition.

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; // unreachable
}

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; // missing NoYes.Probs
  if (x === NoYes.No) {
    x satisfies NoYes.No;
    return 'Nein';
  }
    
  x satisfies NoYes.Yes; // missing NoYes.Probs
  return 'Ja';

  x satisfies NoYes.Yes; // unreachable, missing NoYes.Probs
}

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 could contain 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:

There's some flexibility here though. 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';
}