When does static typing help?
For a while, descriptive variable names, unit tests, and solid documentation can provide enough type information to passably avoid adopting a static type checking system, but eventually, more complex functions and a larger codebase strain. At Mixmax, we used common code styles (both linting and best practices) and documentation formats (we like JSDoc best) to make code clear and readable. As an example, a function definition might look something like this:
For a function with one argument and a simple return value, this works great. It’s clear what the function requires and returns. However, a few versions later, with more requirements, the same function has added a bit of complexity and now looks like this:
The documentation has quickly gotten out of hand with three optional values, multiple nested attributes and unclear return values (what does the return object have if there are no results?). It would be easy to call this function and forget the order of arguments and required properties which changes the behavior of the rest of the code. This is where static type checking comes in handy.
Static type checkers at a high level go through your code and warn of invalid function calls, object descriptions, and other mistakes that wouldn’t be caught with just linting.
Choosing a type system
How can we incrementally adopt them (and revert if necessary)?
How do they perform on hundreds of files in one project?
As a small (but growing) engineering team, it’s important that whichever system we choose provides immediate benefits to productivity and reliability rather than requiring a month-long rewrite or a steep learning curve. Luckily, both Flow and TypeScript support file-by-file transitions, so neither would’ve forced us to rewrite entire projects at once (whew).
Switching away from Flow would be easy, since it’s just transpiled using a babel preset. The results of this transpilation maintain comments, spacing, and other nice-to-haves, just without Flow’s type annotations. As a result, switching from Flow would just be a matter of renaming directories and removing Flow-enabled files.
.js files created by TypeScript’s compiler lacked much resemblance to the original code (removing spacing, comments, etc.). This meant a high barrier to switching back, and switching required some other tool or manually migrating.
Prior to using either Flow or TypeScript, it was important for us as a team to understand the impact our choice would have on our build times and in our editors. Primarily, we wanted to ensure that adding a type system to our build process wouldn’t bloat build times and that it wouldn’t slow down local development constantly waiting for slow compilation (or transpilation). While there aren’t a ton of benchmarks available, there’s some evidence that Flow increases compilation time for every file added and TypeScript does not. Though, in practice, there is no performance difference between the two.
Additionally, given that both frameworks are used successfully in large projects, we’re willing to call this one a draw. One thing we noted is that both TypeScript and Flow offer options to only check changed files which is great for keeping development snappy.
On the other hand, Flow fit nicely into our existing methods, conventions, and best practices. We didn’t have to learn completely new syntax for the benefits of a type system to be immediately apparent. It also let us be as type safe as we wanted. In testing, we found that Flow provided more guarantees without requiring extremely detailed definitions, while TypeScript generally lagged behind in that regard.
In the end, Flow’s drop-in benefits, focus on type-checking, and low transition cost convinced us to start using it throughout our code base. After 4 months, it’s serving us well and continuing to see widespread use throughout our npm packages!