Using Flow's type refinement for powerful, flexible, safer code

Status: draft
Share: Twitter

#Goal

After learning about three concepts: union types, type refinement and disjoint unions, you'll be able to use type refinement to create functions that are flexible and powerful while also keeping your code safe from footgunsfootguns.

We're going to start with a basic vanilla JavaScript example in order to understand how Flow can help us write code that can allows us to maintain the flexibility of JavaScript while still giving us safety with types.

In this example, we're going to write a simple function called say that:

  • takes a string that is one of 4 types of animal: "Dog", "Cat", "Cow" and "Horse"
  • executes a console.log with the noise that animal makes
  • have a default for "I'm not a farm animal!" if someone passes a value we didn't prepare for
const say = animal => {
switch (animal) {
case "Dog":
console.log("woof!");
break;
case "Cat":
console.log("meow.");
break;
case "Cow":
console.log("moooooooo");
break;
case "Horse":
console.log("nay-hay-hay-hay-hay!!!");
break;
default:
console.log("I'm not a farm animal!");
}
};
say("Dog"); // prints "woof!"
say("Cat"); // prints "meow."
say("Cow"); // prints "moooooooo"
say("Horse"); // prints "nay-hay-hay-hay-hay!!!"
say("Human"); // prints "I'm not a farm animal!"

As you can see, all of our animals are saying what we want them to.

Does the switch(animal) pattern above look familiar? You probably have some code that you've written recently that does a check for the value of a variable and do something unique based on it. This is the un-typed version of type refinement.

Type refinement is the ability to determine what to do based on the value a statement receives. If the value of animal is "Dog", console.log("woof!").

As you can see above, our "farm" only has 4 types of animals and we have a catch-all for if someone passes an animal that we don't have in our farm. We only want to do something if we receive one of those 4 types, and we want to let the caller know that we don't support other types of values to be passed to our say function.

Type refinement is possible because of the second concept we're going to explore: union types.

#Union Types

Before we implement Flow in our project, let's talk about union types.

Remember how we wanted to only take one of 4 types of animals and nothing more? That's achieved through union types.

We'll define a type for Animal that is a union type of "Dog", "Cat", "Cow" and "Horse":

type Animal = "Dog" | "Cat" | "Cow" | "Horse";

As you can see, a union type is a single type that can be of any type joined by the pipe (|) character. It's also not restricted to strings, as we'll see in the last section.

Now any statement that takes an argument that is of type Animal can only be called with one of those 4 values. Flow will error (as we'll see in the next section) if the caller passes a value to the function that doesn't match one of those types.

#Implementing Flow

Equipped with our knew found knowledge of union types and our Animal type, we need to implement Flow in our file.

In order to do this we need to:

  1. Add the // @flow pragma so Flow knows to check the file
  2. Create a union type for Animal
  3. Type say to take animal as type Animal
// @flow
type Animal = "Dog" | "Cat" | "Cow" | "Horse";
const say = (animal: Animal) => {
switch (animal) {
case "Dog":
console.log("woof!");
break;
case "Cat":
console.log("meow.");
break;
case "Cow":
console.log("moooooooo");
break;
case "Horse":
console.log("nay-hay-hay-hay-hay!!!");
break;
default:
console.log("I'm not a farm animal!");
}
};
say("Dog"); // Works! => prints "woof!"
say("Cat"); // Works! => prints "meow."
say("Cow"); // Works! => prints "moooooooo"
say("Horse"); // Works! => prints "nay-hay-hay-hay-hay!!!"
say("Human"); // Error!

Now if we run npx flow, we get an error about the call of say("Human"):

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ index.js:27:5
Cannot call say with "Human" bound to animal because string [1] is incompatible with enum [2].
[2] 4│ const say = (animal: Animal) => {
:
24│ say("Cat");
25│ say("Cow");
26│ say("Horse");
[1] 27│ say("Human");
28│
Found 1 error

Now we've made it impossible to pass a value that's not in our union type! This also let's us clean up our code by allowing us to remove the following from our switch statement:

default:
console.log("I'm not a farm animal!");

This say function is coming a long pretty smoothly and we're making progress on making it only do what we want it to, but the current implementation leaves room for mistakes.

After a year of not touching this code, someone comes along and accidentally changes the switch statement so that "Dog" says "meow." and "Cat" says "woof!", that's no good and what's worse: our type system isn't helping us catch that a Dog should only woof and a Cat should only meow:

// @flow
type Animal = "Dog" | "Cat" | "Cow" | "Horse";
const say = (animal: Animal) => {
switch (animal) {
case "Dog":
console.log("meow.");
break;
case "Cat":
console.log("woof!");
break;
case "Cow":
console.log("moooooooo");
break;
case "Horse":
console.log("nay-hay-hay-hay-hay!!!");
break;
}
};
say("Dog"); // Works! (but it's wrong) => prints "meow."
say("Cat"); // Works! => (but it's wrong) prints "woof!"
say("Cow"); // Works! => prints "moooooooo"
say("Horse"); // Works! => prints "nay-hay-hay-hay-hay!!!"
say("Human"); // Error!

Let's take a trip back a year and revisit our initial writing of this function. Instead of taking a string, let's make Animal a union of objects with a type and a unique function per type based on the noise the animal makes:

// @flow
type SayFn = () => void
type Animal =
| {| type: "Dog", bark: SayFn |}
| {| type: "Cat", purr: SayFn |}
| {| type: "Cow", moo: SayFn |}
| {| type: "Horse", neigh: SayFn |};

It's important to notice that every animal has a unique function name, because we're going to call it based on the type so that the type refinement can catch the future mixup.

Let's rewrite the say function to look like this:

const say = (animal: Animal) => {
switch (animal.type) {
case "Dog":
animal.bark();
break;
case "Cat":
animal.purr();
break;
case "Cow":
animal.moo();
break;
case "Horse":
animal.neigh();
break;
}
};

We made a few changes:

  • we switch on animal.type instead of animal (previously a string)
  • we call a unique function for each type instead of a console.log

This brings us to our final concept: disjoint unions.

Animal now has a type key that has a unique value (Flow docs call this a "tagged property") as it's type so that it can be easily distinguished by the type checker. It is a disjoint union because it has a tagged property of type and Flow knows that if the tagged property matches the value, it should use the object defined in the union.

Now let's fast-forward to the point that the developer mixed up the code for "Dog" and "Cat", the change would be the following:

const say = (animal: Animal) => {
switch (animal.type) {
case "Dog":
animal.purr();
break;
case "Cat":
animal.bark();
break;
case "Cow":
animal.moo();
break;
case "Horse":
animal.neigh();
break;
}
};
say({ type: "Dog", bark: () => console.log("woof!") });
say({ type: "Cat", purr: () => console.log("meow.") });
say({ type: "Cow", moo: () => console.log("MOOOO") });
say({ type: "Horse", neigh: () => console.log("NAY-HAY-HAY-HAY-HAY!!!") });
say({ type: "Human", neigh: () => console.log("I don't belong here.") });

When we run yarn flow we get the following error:

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ index.js:12:14
Cannot call animal.purr because property purr is missing in object type [1].
[1] 9│ const say = (animal: Animal) => {
10│ switch (animal.type) {
11│ case "Dog":
12│ animal.purr();
13│ break;
14│ case "Cat":
15│ animal.bark();
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ index.js:15:14
Cannot call animal.bark because property bark is missing in object type [1].
[1] 9│ const say = (animal: Animal) => {
10│ switch (animal.type) {
11│ case "Dog":
12│ animal.purr();
13│ break;
14│ case "Cat":
15│ animal.bark();
16│ break;
17│ case "Cow":
18│ animal.moo();
Found 2 errors

And Flow has kept us from making a party foul and making our animals say the wrong thing!

Even if the developer came along and changed the say calls to this so that it'd "work":

say({ type: "Dog", purr: () => console.log("woof!") });
say({ type: "Cat", bark: () => console.log("meow.") });

Running yarn flow would give us an error like this:

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ index.js:29:5
Cannot call say with object literal bound to animal because:
• property bark is missing in object type [1] but exists in object literal [2].
• property purr is missing in object literal [2] but exists in object type [1].
[1] 5│ | {| type: "Cat", purr: () => void |}
:
26│ };
27│
28│ say({ type: "Dog", purr: () => console.log("woof!") });
[2] 29│ say({ type: "Cat", bark: () => console.log("meow.") });
30│ say({ type: "Cow", moo: () => console.log("MOOOO") });
31│ say({ type: "Horse", neigh: () => console.log("NAY-HAY-HAY-HAY-HAY!!!") });

Letting us know that an object with the type "Cat" should have a purr property and that bark isn't a part of the "Cat" type definition.

#Acknowldgements

Thanks to Lily Scott (Suchipi) for helping me understand these concepts better and teaching me a lot of new things about Flow!

#Pior Art


  1. Functionality added to a product or tool that results in the person shooting themselves in the foot with it. Wiktionary entry
Did you enjoy this article? Share it on Twitter!
Tagged with FlowType, JavaScript
chaseadams.io is powered by GatsbyJS, GitHub & Netlify.
Deployed commit of chaseadams.io is f1a4e1
👋 Say Hi!