TypeScript, Flow and the importance of toolchains over tools

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?

Large JavaScript codebases can become very difficult to maintain. As the number of lines of JavaScript grows, it becomes increasingly difficult to understand and reason about the code that has already been written. This makes to easier to introduce bugs over time.

The root cause of this is that JavaScript is dynamically typed. Without static types it’s difficult to describe what arguments a function should take and what it should return, and difficult to check if those constraints will be violated prior to runtime.

Facebook, Google and Microsoft have known this for a long time. Google made some early efforts to bring JavaScript under control with the Closure compiler and Google Web Toolkit projects. We’ve also seen compile-to-JavaScript languages like Dart or Elm, although they have struggled to get mainstream adoption.

More recently, an alternate approach has been to extend the existing JavaScript language with type annotations. The two most popular implementations of this approach are Microsoft’s TypeScript and Facebook’s Flow, which I’m going to focus on now.

Compare and Contrast

TypeScript advertises itself as ‘a typed superset of JavaScript that compiles to plain JavaScript. In contrast, Flow describes itself as a ‘static type checker for JavaScript’. I think this distinction is significant.

TypeScript

TypeScript is more akin to a traditional compiler. It takes an input (TypeScript files, which have the extension ‘.ts’) and produces an output (JavaScript files with the extension ‘.js’).

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

There are thousands of declaration files available, covering a multitude of JavaScript libraries. However, not all libraries are covered, and even if they are, they won’t necessarily be for the version that you want. Finally, it’s not guaranteed that the person who authored the .d.ts file will have gotten it 100% right, meaning that TypeScript might block you from using the library in a way that is actually legitimate.

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.

redux

Sweet, sweet autocompletion of a Redux state tree with TypeScript & Visual Studio Code. This autocompletion will work all the way down the tree.

In short: there are some benefits in using an IDE with TypeScript, although it’s worth keeping your expectations in check.

Flow

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:

// @flow

Whilst Flow can add some value to plain vanilla JavaScript, the real gains come when you add type annotations to your code. This helps Flow to do far more detailed analysis.

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.

Probably the most significant difference is that Flow uses nominal typing, whereas TypeScript uses structural typing. Put simply, this means TypeScript considers two types to be equivalent if they have the same structure, ie, the same variables and methods. In contrast, Flow will only consider them to be the same if they actually have the same name. Type-system aficionados will probably groan at the clunkiness of that explanation, but the more important point is that one approach isn’t necessarily better or worse than the other, they’re just different and involve different tradeoffs. Maybe in future it’ll become apparent which is more appropriate for JavaScript, but for now, it’s not really clear.

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.

nuclide

Huh, turns out Nuclide telling me I only have 67% coverage for a file, and showing me which lines aren’t covered

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.

JavaScript is notorious for the variety of its tooling, and the complexity of stringing that tooling together. Cumulatively, I have spent many months of my life wrestling with JavaScript toolchains, when I would rather have been writing code that more directly benefited end-users. Consequently, I am always looking for ways to minimise the overhead of grappling with tooling.

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.

 

 

Conclusion

Type checkers are going to be an important part of the future of JavaScript. In this post I’ve tried to explain why, and have given some of my first impressions of the two most popular type-checkers out there: TypeScript and Flow.

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.

 

 

[email protected]

I'm a Senior Consultant at Shine Solutions.

No Comments

Leave a Reply

Discover more from Shine Solutions Group

Subscribe now to keep reading and get access to the full archive.

Continue reading