10-20x speedup for TypeScript transpilation in Bazel

·

4 min read

When I first started working on TypeScript in Bazel, we borrowed Google's approach to running the compiler, called ts_library. It's fast, but it comes with a ton of complexity since it needs a custom compiler binary which hacks into a lot of TypeScript's internal APIs. It was also not compatible with a lot of existing code, since it makes assumptions about module formats to work with Google's closure compiler, which almost no one uses.

So when I left Google at the start of 2020, I worked with OJ Kwon, who I knew from RxJS, to make a new Bazel plugin ("rule") for running TypeScript. This is called ts_project and it's just a thin wrapper around the tsc binary distributed by the TS team at Microsoft.

We gained a ton of simplicity and compatibility, which has been great to allow us to maintain this tooling with a band of OSS volunteers. However, we lost speed because we start a new, cold tsc process for each compilation, rather than using a "watch mode". One of our OSS contributors hooked up a watch mode ("persistent worker" in Bazel lingo) but it didn't scale well since a separate compiler had to be warm for each library in the project. As a result, we've never deprecated the ts_library rule, and users have been stuck with a choice between a poorly-maintained thing and a slow thing.

I'm happy to report that this problem is fixed!

Amusingly, OJ and I got to meet again, now that he works on SWC. This is a very fast TS -> JS transpiler written in Rust. Its own benchmarks show that it is 20x faster than alternatives. Even better from Bazel's perspective: swc performs just as well when it is "cold", running as a new process, as it does in a "watch mode".

Thus our change to Bazel's ts_project is to introduce a transpiler attribute where you can choose any tool you like. SWC is a good choice, but you might need to use another tool due to specific transforms your code relies on. For example, many projects use Babel.

Note that Bazel will still use tsc to type-check the code when requested, however this isn't triggered when you just want to build a JS bundle or run a devserver. We observed most developers had a TypeScript Language Service running in their editor, so they already got the "red squiggles" on their mistakes and don't need the build system to repeat that before running the program. If you'd like, you can still run the checker under Bazel by building the _typecheck target (or use a wildcard like my_package:all). You'd probably do that only when you're done with a change, and then of course that wildcard is used on CI to ensure code is checked before it's merged.

Demo

Here is a Bazel config to run tsc:

load("@npm//@bazel/typescript:index.bzl", "ts_project")

# Uses TypeScript (tsc) for both type-checking and transpilation
# % bazel build tsc
# INFO: Elapsed time: 6.798s, Critical Path: 5.24s
ts_project(
    name = "tsc",
    srcs = ["big.ts"],
    out_dir = "build-tsc",
    tsconfig = _TSCONFIG,
)

Over 5 seconds is too slow for a project with a single file!

Here's what it looks like to use swc instead:

load("@aspect_rules_swc//swc:swc.bzl", swc = "swc_rule")

# Runs swc to transpile ts -> js
# and tsc to type-check.
# % bazel build swc
# INFO: Elapsed time: 0.745s, Critical Path: 0.54s
#
# Optionally, or on CI, you can explicitly do the slow type-check:
# $ bazel build swc_typecheck
# INFO: Elapsed time: 3.330s, Critical Path: 3.19s
ts_project(
    name = "swc",
    transpiler = swc,
    srcs = ["big.ts"],
    out_dir = "build-swc",
    tsconfig = _TSCONFIG,
)

That's 10x faster. In real world cases we've seen improvements over 20x.

Just for completeness, here is babel:

# This is <20 SLOC Bazel macro to adapt the Babel CLI to the ts_project API
load("babel.bzl", "babel")

# Runs babel to transpile ts -> js
# and tsc to type-check
# % bazel build babel
# INFO: Elapsed time: 3.928s, Critical Path: 3.73s
#
# Like the swc example, you could build babel_typecheck to run tsc.
ts_project(
    name = "babel",
    transpiler = babel,
    srcs = ["big.ts"],
    out_dir = "build-babel",
    tsconfig = _TSCONFIG,
)

That's only a mild speed improvement, however it's still an improvement over earlier ts_project since you can use the same Bazel config to declare both transpile and type-check for the TS sources.

The full example is here: https://github.com/aspect-build/bazel-examples/tree/main/ts_project_transpiler

Thank you to our friends at EngFlow for sponsoring the OSS work in rules_nodejs to add this attribute! I hope that after this feature "bakes" for a few weeks, we'll finally be able to deprecate the old ts_library.