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
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.