Case Exhaustiveness in Flow

Compared to some other languages, Flow’s story around exhaustiveness checking within if / else and switch statements leaves something to be desired. By default, Flow doesn’t do any exhaustiveness checks! But we can opt-in to exhaustiveness checking one statement at a time.

In this post, we’ll discover from the ground up how Flow’s exhaustiveness checking behaves. But if you’re just looking for the result, here’s a snippet:

TL;DR

type A = {tag: "A"};
type B = {tag: "B"};
type AorB = A | B;

const absurd = <T>(x: empty): T => {
  throw new Error('This function will never run!');
}

const allGood = (x: AorB): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else if (x.tag === "B") {
    return "In branch B";
  } else {
    return absurd(x);
  }
}

const forgotTagB = (x: AorB): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else {
    // B. This type is incompatible with the expected param type of empty.
    return absurd(x);
  }
}

How Exhaustiveness Behaves in Flow

Read on flow.org/try/

Here we have a type AorB with two variants;

type A = {tag: "A"};
type B = {tag: "B"};
type AorB = A | B;

const fn1 = (x: AorB): string => {
  if(x.tag === "A"){
    return "In branch A";
  } else {
    return "In branch B";
  }
}

All well and good, but what if we add a new case? For example, what if we take the snippet above and add this:

type C = {tag: "C"};
type AorBorC = A | B | C;

const fn2 = (x: AorBorC): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else {
    return "In branch B";
  }
}

Wait a second, it type checks!

That’s because we used a catch-all else branch. What if we make each branch explicit?

// ERROR:                 ┌─▶︎ string. This type is incompatible with an implicitly-returned undefined.
const fn3 = (x: AorBorC): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else if (x.tag === "B") {
    return "In branch B";
  }
}

Phew, so it’s reminding us that we’re not covering all the cases. Let’s add the new C case:

// ERROR:                 ┌─▶︎ string. This type is incompatible with an implicitly-returned undefined.
const fn4 = (x: AorBorC): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else if (x.tag === "B") {
    return "In branch B";
  } else if (x.tag === "C") {
    return "In branch C";
  }
}

Hmm: it still thinks that we might return undefined, even though we’ve definitely covered all the cases… 🤔

What we can do is add a default case, but ask Flow to prove that we can’t get there, using Flow’s empty type:

const fn5 = (x: AorBorC): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else if (x.tag === "B") {
    return "In branch B";
  } else if (x.tag === "C") {
    return "In branch C";
  } else {
    (x: empty);
    throw new Error('This will never run!');
  }
}

The throw new Error line above will never run, because it’s not possible to construct a value of type empty.Of course, this presumes that Flow’s type system is sound, which it isn’t. It’s possible to accidentally inhabit empty if you use any! Moral of the story: be very diligent about eradicating any.

(“There are no values in the empty set.”)

If we adopt this pattern everywhere, we’d see this error message if we forgot to add the new case for C:

const fn6 = (x: AorBorC): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else if (x.tag === "B") {
    return "In branch B";
  } else {
    // C. This type is incompatible with empty.
    (x: empty);
    throw new Error('absurd');
  }
}

Flow tells us “Hey, I found a C! So I couldn’t prove that this switch was exhaustive.”

But this pattern is slightly annoying to use, because ESLint complains:

no-unused-expressions: Expected an assignment or function call and instead saw an expression.

We can fix this by factoring that empty ... throw pattern into a helper function:

// 'absurd' is the name commonly used elsewhere for this function. For example:
// https://hackage.haskell.org/package/void-0.7.1/docs/Data-Void.html#v:absurd
const absurd = <T>(x: empty): T => {
  throw new Error('absurd');
};

const fn7 = (x: AorBorC): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else if (x.tag === "B") {
    return "In branch B";
  } else if (x.tag === "C") {
    return "In branch C";
  } else {
    return absurd(x);
  }
}

const fn8 = (x: AorBorC): string => {
  if(x.tag === "A") {
    return "In branch A";
  } else if (x.tag === "B") {
    return "In branch B";
  } else {
    // C. This type is incompatible with the expected param type of empty.
    return absurd(x);
  }
}

So there you have it! You can put that helper function (absurd) in a file somewhere and import it anywhere. You could even give it a different name if you want! I’ve been using this pattern in all the Flow code I write these days and it’s been nice to rely on it when doing refactors.