What I wish I had known before starting to use Flow

I've been using Flow at work for more than a year and there's a few things that I really wish I had known from the start. Some of these could have been found by simply reading the entire documentation from start to finish (a great habit when picking up a new tool!), but not all of them. As such, I decided to write a small article about some of Flow's caveats and other potentially unexpected characteristics of Flow.

(For those of you who haven't heard of it, Flow is a static type checker for JavaScript. This quick introduction should be enough context for this blog post.)

Typescript and Flow don't play along very well

Since TypeScript and Flow have very similar syntaxes, I somewhat expected their library definitions to be compatible. Unfortunately, the library definition syntaxes differ enough to make them incompatible. I recently converted the definition of an open source library from Typescript to Flow. I tried to use an automatic converter (flowgen) and it got most of the work done, but I still had to manually clean up some of the syntax. This is important to keep in mind because the ecosystem is becoming fragmented (some projects only have Flow definitions whereas others only have TypeScript definitions). Overall, I would say that TypeScript currently seems to be the most popular of the two and it is generally easier to find TypeScript definitions for JavaScript libraries.

Flow's configuration is really important

.flowconfig is the config file for Flow and it should not be underestimated, especially for new projects. The best resource for learning about Flow's configuration is the official documentation. However, I'd like to pinpoint a couple of configuration options that can make or break Flow.

The first of these options is linting configuration. Flow is not only a static type checker but also a JavaScript linter. Of course, Flow's linter is not as generic as eslint. Instead, Flow's linter is more focused on potential issues with your code that are related with types. Let's take an example at one of these rules – sketchy-null:

const x: ?number = 5;
if (x) {} // sketchy because x could be either null or 0.

By default, Flow will not give you an error for this type of thing (which is actually very dangerous). However, you can configure Flow to give you errors (or warnings) for this and many other linting rules. I highly recommend looking into the various linting options and setting them up to error from day 1. Of all the linting options that Flow provides, I only consider 2 of them to be harder to enable (unless they are turned on very early in a project):

  1. unclear-type – errors on any usage of any, Object or Function
  2. untyped-import – errors when importing any module that is not typed. In some projects, it might be possible to type all the dependencies, but it's not always worth it when certain time constraints are in place.

Besides linting, there's many other configuration options which can make Flow much better. I always encourage new users of Flow to quickly read through all the configuration options.

Set up flow-coverage-report

As we've just seen, Flow does not force you to fully type all of your code (unless you enable all the linting rules, including unclear-type and untyped-import). At work, we currently have Flow covering 87% of our code (we really want to improve this though). The way to get this information is with flow-coverage-report. We set it up in our package.json like this:

{
  ...
  "scripts": {
    ...
    "flow-coverage-report": "flow-coverage-report -i 'src/**/*.js'",
    ...
  }
}

$ npm run flow-coverage-report gives us a really nice report of what Flow's coverage looks like across the entire codebase. I also recommend hooking up Flow's coverage report to your CI system to prevent type coverage from slipping as new code gets added.

Accessing "maybe-there" properties is not possible (as well as some other things)

In JavaScript, if you try to access a property that does not exist in an object, you get back undefined (e.g., ({}).foo = undefined). This is expected and a lot of JavaScript code out there depends on this functionality. However, Flow does not let you do this:

type A = {|
  message: string,
|} | {|
  error: string,
|};

function myFunction(data: A) {
  var b = data.error; // Flow errors here with "Cannot get `data.error` because property `error` is missing in object type [1]"
}

This may be unintuitive when working with JavaScript but the way to "solve" this in Flow is using Disjoint Unions.

There's a few other things that are "allowed" in JavaScript but that Flow will not let you do. For instance, "a" + null is invalid in Flow, despite being valid in regular JavaScript. One way to wrap your head around this is to think of Flow as a subset of JavaScript. This is a very welcome aspect of Flow, since JavaScript lets you do too many weird things, especially due to implicit casting (Flow has an explicit type system).

Be very careful about which version of Flow you're using

I follow Flow's releases very closely and manually update our package.json on every new release. This is a very healthy habit because it forces me (and the rest of the team) to keep up with what Flow adds or changes on every releases.

At the same time, make sure every engineer is using the same version of Flow (as with any dependency in JavaScript).

Utility Types may look scary but are extremely useful

Flow includes some "advanced" utility types which are only somewhat documented. When I started using Flow, I thought these were pretty daunting. However, I've come to really appreciate how useful they are. Let's take a look at one of the examples from Flow's documentation:

// @flow
const countries = {
  US: "United States",
  IT: "Italy",
  FR: "France"
};

type Country = $Keys<typeof countries>;

const italy: Country = 'IT';
const nope: Country = 'nope'; // 'nope' is not a Country

How awesome is this? $Keys and other advanced utility types are very useful and allow for much safer code.

Beware of type invalidations

Flow supports type refinements and it performs type refinement invalidations as well. A type refinement is when the type checker (in this case Flow) knows that the program is refining (or restricting) the type of a variable, like in this example:

type Action = {
  type: "A",
  val: number,
} | {
  type: "B",
  val: string,
};

const parse = (action: Action) => { 
  if (action.type === "A") {
    // flow knows that action.val is a number in this block
    (action.val: number);
  } else {
    // flow knows that action.val is a string in this block
    (action.val: string);
  } 
};

On the other hand, Flow also invalidates the type of a variable or expression when it can't know for sure what happened to it. Let's look at an example:

declare function filter<T>(arr: Array<T>, predicate: T => boolean): Array<T>;

type Action = {
  type: "A",
  val: number,
} | {
  type: "B",
  val: string,
};

const parse = (action: Action) => {
  const arr = [1, 2, 3];
  
  if (action.type === "A") {  
    filter(
      arr,
      x => x < action.val // Cannot compare number to string
    )
  }
};

In this example, we declare a third party function called filter which filters an array based on some predicate function that returns a boolean.

We then declare a union type called Action which can either hold a number or a string.

Finally, in the parse function, we try to filter an array of numbers based on the value stored in an Action type object. However, because we do this in a callback which Flow can't read (we only declared the filter function as third party code), Flow doesn't trust it and errors. Flow is pessimistic and errors because it's theoretically possible that the filter function could call the predicate function that was passed to it at a later time, when the action object could have been changed.

The best way to go around this problem is to store safe references to variables that you pass to callbacks, like this:

declare function filter<T>(arr: Array<T>, cb: T => boolean): Array<T>;

type Action = {
  type: "A",
  val: number,
} | {
  type: "B",
  val: string,
};

const parse = (action: Action) => {
  const arr = [1, 2, 3];
  
  if (action.type === "A") {
    const val = action.val;

    filter(
      arr,
      x => x < val
    )
  }
};

The first few times I hit type invalidations in Flow it was quite frustrating and I thought that Flow wasn't "smart enough". However, I have now grown to appreciate them.

Editor integration is a major productivity boost

One of the main advantages of using Flow is the empowered editor tooling thanks to static types. I'm currently using Code for writing Flow as well as the flow-for-vscode extension.

This tooling (for which there are equivalent extensions for many other text editors and IDEs) makes writing JavaScript fun. You get all the niceties of statically typed languages:

  • Type-based auto completion
  • Jump to definition
  • Real time type errors

Notice that the Flow watcher will routinely use close to 1 gigabyte of RAM on my machine, so you might want to get proper development machines to use Flow. On the plus side, it is blazing fast. You can read more about Flow's performance in this paper written by the Flow team at Facebook (their codebase has millions of lines of JavaScript covered by Flow).

As a bonus, here are some recommended changes to Code's settings in order to improve it support for Flow:

// Auto-detect Flow's binary on a per-project basis.
"flow.pathToFlow": "${workspaceRoot}/node_modules/.bin/flow",

// Disable default JavaScript validations so that we only get
// messages from Flow.
"javascript.validate.enable": false,

// Disable non-Flow auto completions so that we only get
// type-based auto completions from Flow.
"javascript.nameSuggestions": false,

Object types are not sealed "by default"

If you declare an object type like this one:

type Dog = {
  name: string,
  breed: "Labrador" | "Bulldog",
};

You can then create instances of this type with added properties, like this:

const puppy: Dog = {
  name: "Lilly",
  breed: "Labrador",
    
  age: 7,
  weight: 45,
};

No errors. This is fine as you can mark an object as "sealed" by using the {| … |} syntax instead. However, it's too easy to have unsealed object types. Because of this, I recommend doubling down on using sealed object types in code reviews. It's quite rare that unsealed objects are actually necessary.

Conclusion

Fully typing my JavaScript has been an incredibly rewarding experience. The programming experience is so different that sometimes I like to think that Flow is a language of its own and not just a "static type checker" for JavaScript. These are some of the reasons why I like Flow so much:

  1. Overall, I can write code and ship new features much faster, mostly due to the editor integrations for Flow.
  2. No more runtime type checking which is cumbersome and increases bundle size (e.g., React PropTypes). Runtime type checking also delays development because developers have to actually run the code instead of just waiting for Flow's verdict.
  3. I don't have to waste time writing tests for trivial things such as passing an argument of the wrong type to some arbitrary function
  4. The code is much easier to read and reason about. You don't have to guess what type things are and what fields an object can have (just hover it in your text editor!). You also need fewer documentation/comments in your code.
  5. In general, designing data types first and then writing the code around it allows for simpler code. It's easier to make my code more declarative and less imperative because I focus on my data first.
  6. Unlike in some statically typed languages, Flow infers types quite well (not as well as OCaml). This means that code does not have to be overly annotated and verbose. It also means that developers don't have to spend too much time manually annotating all their variables and functions.

It's exciting to see the JavaScript ecosystem moving towards being more typed.