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
= {tag: "A"};
type A = {tag: "B"};
type B = A | B;
type AorB
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;
= {tag: "A"};
type A = {tag: "B"};
type B = A | B;
type AorB
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:
= {tag: "C"};
type C = A | B | C;
type AorBorC
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.