Revisiting TypeScript

February 10, 2016

After three years of surveys, I'm working on something new at work: investigating using the TypeScript language for future Google apps. Whether that will go anywhere is still unclear to me, but in the meantime I've learned a new appreciation for TypeScript and I thought I'd share what I've learned with you.

Most TypeScript tutorials open by diving into the type system, as that is the new shiny thing. But for the purposes of deciding whether you should use TypeScript for your next project the typing part is almost irrelevant, and can just be summarized as "it adds a static type system to JavaScript that is designed to work with existing libraries". If you like static types then you probably know more or less what that implies.

Instead here's all the stuff I wished I had known about other than the type system.

Services daemon and editor integration. The TypeScript compiler is designed as a daemon that can answer questions about your code (e.g. "what is the type of the variable foo?"). This design means that your editor can call into the daemon to get all the fancy IDE-like features — such as code-aware variable renaming or clicking on a function to jump to its definition.

In fact this interface is already implemented by many editors, including the IDEs but also Sublime Text, Vim, and Emacs. I personally use Emacs and get red squiggly lines in my editor under code with errors in it.

Project layout. For this services daemon architecture to work, your editor must be able to invoke TypeScript in the same way (with the same options and input paths) as you would at build time. To do this they standardized on a shared config file that specifies these settings: when invoked, the tooling looks for your project root by crawling directories upwards until it finds a tsconfig.json, which contains all the settings.

This means that (1) when creating a new project, you need one of these files in the root directory (it's created by running tsc --init, much like how you'd run git init in a new project); and (2) to build, you always invoke the compiler with no arguments, as they are all specified by the tsconfig.json.

In my work I've found this model serves as a brake to keep build configs from getting too crazy, because if you're doing something in your build system that can't be expressed in tsconfg.json then you lose your fun editor features.

Incremental compilation. In previous attempts with TypeScript I was frustrated by the compilation speed. To be clear, the compiler is decently fast — a second or two for a small project, perhaps comparable to whatever today's ES6 transpiler is once you layer in the build systems and plugins and whatever goop you use to execute it — but compilation was slow enough that it broke the flow of reload debugging (as you might experience in a plain JS project). It turns out that TypeScript is actually designed to work faster than that, but you must let the compiler process hang around between compilations via the above daemon architecture.

In practice this means you run tsc --watch when writing code and it incrementally recompiles whenever you save. In an internal project I found the compiler was fast enough that you could actually recompile on every keystroke because it took under 20 milliseconds to recompile a hot small file. (Experimenting with tsc --watch produced compilation times more like a second on my laptop. From a peek at the code I saw that it doesn't compile immediately on save but instead waits a bit first to debounce multiple saves.)

It's also worth noting that if you're using one of the above-mentioned editor integrations (which you should), then your editor is marking errors while you write code and you will only build when you intend to run the code.

Module systems. TypeScript accepts ES6 JavaScript as input and can produce ES5 as output. ES6 has a syntax for importing modules (which is another way of saying "multiple-file projects"), which TypeScript cares about because it must traverse files to check types. But in terms of generated code it just transpiles imports into equivalent imports in another module system that you configure; for example, one option for output is the var foo = require('foo'); used by nodejs and some others.

If you want to actually build a single JS file from multiple inputs you must use some sort of bundler or module loader external to the TypeScript compiler. You might notice that the compiler has some flags that let you specify a single output file; to use that output in a browser unfortunately still requires the involvement of a module loader library. (Of course, if you're targeting nodejs then you can just produce its import syntax and everything just works.)

Implicit any. One way to use TypeScript is to add it incrementally to a project by just renaming *.js to *.ts. By default, places where you don't provide a type annotation (like in function arguments) are then understood to have type any, which effectively means types are unchecked. In this model the thinking is that TypeScript will locally analyze functions (where types are inferred), then you can add annotations as you see fit to discover more bugs.

The other way to use TypeScript is to have the compiler tell you when it's guessing. The compilation flag noImplicitAny instead makes it a compiler error to leave out the types when they are needed. This flag can be flipped in the tsconfig.json.

Conclusion. In all, I've been pretty impressed by TypeScript. It feels right to me in the engineery sense, where they have made good tradeoffs in the pursuit of making a useful tool. I'm hopeful for its future.