05 Jan 2017 TypeScript, Flow and the importance of toolchains over tools
EDIT: The initial version of this post stated that the TypeScript compiler won’t emit code if it finds errors in the source. This is not correct. By default, the compiler will emit code even if it detects errors, unless the –noEmitOnError flag has been set. This post has been updated accordingly.
I’ve recently been working on a project that uses TypeScript. I also have been playing with Flow on a personal project. In this post I want to talk about why I think these tools are important, some of the fundamental differences between them, and why the choice of which one to use might best be determined by the broader toolchain that you are working within, rather than just the particular technical merits of one or the other.
Why a Type Checker?
Compare and Contrast
You have some say in how strict it will be about its type-checking. Most significantly, there is a compiler flag called ‘noImplicitAny’ that, if enabled, will require you to specify types for all the arguments and return value of every function that you use.
Switching on noImplicitAny can have serious implications for your workflow, especially if you want to work with third-party libraries. In order to check that you are using a library correctly, TypeScript introduces the concept of declaration files, which commonly have the suffix .d.ts. A declaration file uses TypeScript to define the interface to a library.
Writing declaration files can be hard, but fortunately there is a existing database of them that you can access via the npm repository. For example, if you wanted to add Sinon to your project, you’d first add the actual NPM module:
npm install --save sinon
Then you’d install the type definition:
npm install --save @types/sinon
If you have noImplicitAny enabled and try to introduce a new third-party library to your codebase, TypeScript will insist that you provide a type definition for it. The implication of all this is that, if no correct type definition is available, you’ll have to provide one yourself. This means you have to write a dummy definition that at the very least uses <any> types, and deploy it into your project yourself.
All of this adds friction to the process of adding new libraries to a project. I have found this to be a pain when trying to conduct quick experiments with libraries to determine whether they were going to solve a particular problem I had. If you don’t want to have to jump through that particular set of hoops, you have to temporarily switch off noImplicitAny and then remember to switch it back on later.
TypeScript support is baked into Visual Studio Code (VSCode). Beyond the standard in-line display of compiler errors, the chief selling point of using an IDE with TypeScript is click-through code navigation, refactoring and auto-completion support. At this stage automated refactoring is limited to renaming, although the TypeScript compiler gives you an extra safety net when manually executing more complex refactorings.
I found the auto-completion support in VSCode to be of limited use, with one notable exception: navigating Redux state. State shape is super important on a Redux project. Declaring types for the state shape and then being able to quickly navigate my way to a particular point within it was a huge win for me.
In short: there are some benefits in using an IDE with TypeScript, although it’s worth keeping your expectations in check.
In contrast to TypeScript, with Flow there is no real ‘compilation’ step. Instead, Flow analyses your regular .js files to look for potential errors. It’s kind of like a linter – a really, really smart linter. Furthermore, rather than just analysing types, it can look into the actual structure of your program, right down to the if blocks and for loops. This means that it can analyse the control flow of your program, hence the name ‘flow’.
To have Flow analyse a particular module, you have to opt-in by adding the following to the top of the module’s file:
In Flow, type annotations look similar to the annotations used by TypeScript. However, the similarity ends there. When it comes to declaring complex types and data structures, both the syntax and semantics used by Flow differ from TypeScript.
To enable analysis of your interactions with third-party libraries, Flow has its own library definition mechanism that is vaguely similar to TypeScript’s, but not as well established. By default Flow will not try and check against a third-party module unless you have provided a definition file.
Once Flow has done its analysis, there’s no need for the annotations and type declarations anymore. So all you need to do to run your code is put it through a simple processor that strips them out. You can do this regardless of whether Flow found any errors or not. Your build pipeline doesn’t have to work that way if you don’t want it to – in fact, for any large project you probably wouldn’t want it work that way – but the default position with Flow seems to be as unobtrusive as possible.
There is nascent Flow support being built for a number of different IDEs. The most mature is probably Nuclide, a purpose-built extension to Atom being created by Facebook for development with their most popular languages and tools. Nuclide provides basic inline error support for Flow, which alone probably makes it worth looking at. It also has autocomplete support, although in the limited time I worked with it I never got particularly good suggestions.
One interesting feature of Flow I stumbled across whilst using Nuclide was the concept of coverage. In short, this is the proportion of your code that is actually being covered by Flow’s type checks. It’s something that the Flow command-line tool can report to you, but in Nuclide the editor itself is able to show which lines are being covered, and which aren’t.
It wasn’t until I saw my coverage stats that I had a clear picture of how much coverage Flow was actually giving me. In short, it was usually less than I thought. To increase it, I had to add more type annotations.
In summary, in comparison to TypeScript, Flow feels more like something that can be bolted onto the side of a project, even whilst the project is in mid-flight. The default tooling configuration seems geared towards incremental introduction. The downside of this is that sometimes you might have a false sense of security about exactly how much safety Flow is giving you at a particular point in time. You mightn’t have added @flow annotations to a particular file, or have given it enough information to comprehensively analyse your code.
Tools vs. Toolchains
In comparing TypeScript and Flow it can be tempting to fixate on the differences between their type systems, or to focus on how incrementally you’ll be able to introduce it to your codebase. However, when deciding which to use for a particular project, I think it’s important to take a big step back and consider how easily it will fit into your overall toolchain.
For example, if you were starting an Angular project tomorrow, I would almost definitely recommend you go with TypeScript. This decision isn’t about TypeScript being superior to Flow or not. Instead, it’s about the cohesiveness of Angular as a platform. Angular 2 is built with TypeScript, and designed in the first instance to work with TypeScript. This means the ‘happy path’ for coding, testing and building an Angular 2 app is all geared around TypeScript.
Sure, you could use Flow if you want, but once you stray off of that path you should not be surprised if you find yourself hacking around in the weeds by yourself. Put differently, it’s not that you can’t do it, it’s just that it will probably take up time – lots of time.
Similarly, I think that if you’re starting a React project, you should consider Flow in the first instance over TypeScript.
Why? Because Facebook have a strong interest in making that particular toolchain as seamless as possible. They have been working hard to standardise their open source toolchain. Furthermore, they have teams of developers using it every day to build stuff internally. The end result is that React, React Native, Jest and even the Create React App project have all been engineered to work most easily out-of-the-box with Flow.
In my experience, prising apart such a toolchain in order to insert a foreign component can have a bunch of unintended consequences. If the rest of the toolchain works best on the assumption that Flow is being used, why work against that assumption in the first instance? Sure, it’s possible to use React Native and Jest with TypeScript, but is it really worth the effort? I’m not sure that TypeScript has enough benefits over Flow to justify it.
Does this mean that I think Flow is superior to TypeScript? Not necessarily. But I do think that having a consistent and reliable developer experience trumps the gory details of which particular type checker you use. It means that you are able to on-board new starters more easily, and your build is less likely to become a special snowflake that can only be handled by a few select members of your team.
Probably the most notable technical difference between the two is how easily they can be incorporated into existing projects. TypeScript feels more mature but also more committing, whereas Flow seems easier to introduce incrementally, albeit with the caveat that it might be checking less of your code than you realise.
That said, I think that the technical superiority of one or the other is less important than its compatibility with the toolchain that surrounds it. For Angular projects, TypeScript is the better choice, as Angular is built with TypeScript. For React projects, Flow should be your starting point, as Facebook have optimised their toolchain for it.