ts-

blank-space

playground

What is ts-blank-space?

ts-blank-space is a fast type-stripping compiler that converts TypeScript to JavaScript. It supports a modern subset of TypeScript by erasing the types and replacing them with whitespace. That's it. It is not a type checker and does not perform any other code transformations.

The underlying technique can improve build performance and simplify development. The implementation is pure TypeScript. It is simple enough to read and understand in a few minutes because it is only 700 lines of code and reuses the original TypeScript parser.

Type-Stripping

The core idea is to use spaces as a substitute for type annotations. You can think of ts-blank-space as a reference implementation of this type-stripping technique. Much like other compiler techniques seen in the JavaScript ecosystem, such as tree-shaking, it is a reusable idea that any TypeScript compiler could implement. Some already do!

Let's look at some examples. If you use the classic TypeScript (tsc) compiler today without customizing any options, you'll see your source code undergo significant transformations.

class Cat<T> {
  public whiskers: number;
  public tail: T;

  constructor(count: number, tail: T) {
    this.whiskers = count;
    this.tail = tail;
  }
}

throw Error();

is compiled to:

var Cat = /** @class */ (function () {
    function Cat(count, tail) {
        this.whiskers = count;
        this.tail = tail;
    }
    return Cat;
}());
throw Error();

That's because TypeScript is performing "down-leveling" to convert your modern JavaScript into something that can run on older JavaScript engines. Because this moves the code around, error stacks and breakpoint locations will not match; so sourcemaps are also generated to correct for this.

Let's try disabling down-leveling using the tsconfig.json option target: "esnext":

class Cat {
    whiskers;
    tail;
    constructor(count, tail) {
        this.whiskers = count;
        this.tail = tail;
    }
}
throw Error();

This looks much closer to the original code with the types erased. But the output is still visually smaller and occupies fewer lines. This means we're going to have inaccurate error stacks.

// Original source
Error
    at Object. (cat.ts:11:7)
// With TypeScript's output
Error
    at Object. (cat.js:8:7)

One solution to this relocation problem is to ask TypeScript to generate sourcemaps. After all, debuggers and runtimes know how to use sourcemaps to translate the filenames and coordinates used for breakpoints and error stacks. But could we do better?

Yes! Here's what the generated code looks like with ts-blank-space:

class Cat    {
         whiskers        ;
         tail   ;

  constructor(count        , tail   ) {
    this.whiskers = count;
    this.tail = tail;
  }
}

throw Error();

This might look a little wacky. However, by leaving the code coordinates unchanged, we've also eliminated the need to generate sourcemaps. There's no encoding and decoding locations. Error stack coordinates are always accurate!

Finally, we can teach the JS engine and debugger about the original filename by adding one line (//# sourceURL=cat.ts) to the end of the JS file. This magic comment is part of the sourcemap specification and is supported natively, so we end up naming files correctly in error stacks with no loss of performance.

Build Performance Benefits

The typical build operations to emit JavaScript code from TypeScript source involves:

  1. Parsing the TypeScript into TypeScript's Abstract Syntax Tree (AST)
  2. Transforming this into a JavaScript AST
  3. Generating code from the JavaScript AST
  4. Sourcemap mapping calculation from the generated code to the original locations in the AST

ts-blank-space replaces steps 2, 3, 4 with basic string substitution on spans extracted from the AST. The AST is used merely to discover the start and end points of the type annotations in order to shake them off.

Skipping this work is what makes this a swift approach. Additional time is saved by decreasing the number of sourcemaps that need to be merged during build scenarios that involve a sequence of transforms and corresponding sourcemaps.

Build Performance Results

Here's a micro-benchmark of what happens when compiling a huge TypeScript file (52 KLoC) ten times using a variety of build tools.

a bar chart comparing the times to run the benchmark. From fastest to slowest the results are: esbuild, swc, ts-blank-space, swc/wasm-typescript, sucrase, swc/wasm, esbuild-wasm, typescript, and babel. esbuild and swc are categorized as having native binaries in contrast to ts-blank-space being distributed as JavaScript

For the supported subset of TypeScript, ts-blank-space is 5.6x faster than tsc. Today, it appears to be the fastest emitter written in JavaScript, and it even beats the Wasm-based emitters. Credit for this speed should primarily be attributed to the highly-optimized TypeScript parser that is doing the heavy lifting. It is also worth calling out the impressive speed of sucrase, which is also written in JavaScript and is only marginally slower even though it supports additional syntax.

Performance Results with AST Re-use

At Bloomberg, we want to use the TypeScript AST for both JavaScript-based analysis and emitting JavaScript. This means we are already paying the cost of generating the TypeScript AST, so ts-blank-space can operate on this as-is.

Here's a similar benchmark in which we measure the combined time to (a) emit JavaScript and (b) generate a JavaScript-accessible AST. babel, @swc/core, and @swc/wasm have APIs to produce the AST. sucrase and esbuild do not provide access to an AST. For these, we fall back to using typescript to provide the AST.

a bar chart with the same results as the previous chart, however with AST access overhead added to the times. Now the results from fastest to slowest are ts-blank-space, esbuild, swc, sucrase, swc/wasm-typescript, swc/wasm, esbuild-wasm, typescript, and babel. ts-blank-space, typescript, and babel have essentially no AST access overhead in contrast to the other libraries that have more significant overheads

With these requirements, we've found ts-blank-space provides the fastest overall results.

On large files, such as those used in the benchmark, we've seen Garbage Collection (GC) account for up to 17% of build time. With internal benchmarks of real-life code, the file size is typically smaller. Here we've seen ts-blank-space go even faster, with GC dropping closer to 3%.

The full code and results of the benchmark can be found in the perf folder of the repository.

Development Time Benefits

Simpler Startup

Debuggers require sourcemaps to be loaded and processed before app startup in order to reliably set breakpoints in terms of generated coordinates ahead of first execution. Eliminating sourcemaps means fewer moving parts and potentially faster startup.

Faster Errors

Avoiding sourcemaps can also increase runtime performance with respect to Error stacks. Node has the ability to toggle whether runtime processing of Error stacks uses sourcemaps. Node includes a microbenchmark which demonstrates that the cost of reading a mapped error stack is approximately twice that of an unmapped error stack.

> node benchmark/es/error-stack.js
es/error-stack.js n=100000 method="without-sourcemap": 107,059.95251565645
es/error-stack.js n=100000 method="sourcemap": 55,918.729592805634

Resilience

Bloomberg is a heavy user of sourcemaps, and we have some engineers who are helping to make them even better. For sourcemaps to work, many different tools must perfectly align end-to-end. This can lead to them sometimes failing. With ts-blank-space your debugging experience will be based on accurate locations even if your environment is misconfigured or you find yourself in situations when sourcemaps would have been inaccessible.

Maintenance Benefits

The default parser used by ts-blank-space is the official TypeScript parser. This maximizes alignment with the upstream TypeScript project. Specifically, this means that it should be trivial for ts-blank-space to support any new syntax that is added to the TypeScript language.

This reduces the effort and compatibility risk associated with maintaining another parser. It also means the project benefits from any performance or correctness improvements that are made to the central TypeScript parser.

Beyond the parser, the remaining code is only 700 lines of TypeScript. This small size makes it easy to review and understand by TypeScript users.

Limitations

ts-blank-space does not support every TypeScript syntax. As such, it may not be suitable for your needs.

JSX is tolerated but not compiled. This means it preserves the tags in the output: *.tsx becomes *.jsx.

We refer to the supported subset as Modern TypeScript because it represents nearly all TypeScript syntax, except for those TypeScript-specific features that may be considered legacy or discouraged in some way - despite not being formally deprecated.

These unsupported TypeScript features already have preferred alternatives:

The following unsupported TypeScript features must be avoided when using ts-blank-space. Unfortunately the alternatives are not always as concise. We would be excited if more powerful and coherent alternatives than these were to succeed as JavaScript language proposals.

Note that ambient Namespaces and ambient Enums are supported (i.e., declare namespace, declare enum).

We've successfully used this TypeScript subset within Bloomberg for many years, following the clear mental model that TypeScript is JavaScript + Types. During this time, the primary unsupported feature that users said they missed was Enums. Most of the time, that need was appeased via the alternative patterns.

Getting Started

ts-blank-space is just a library that transforms strings. It is a single function. It is not a CLI or toolchain.

Should I use this library?

Only if you want to. While ts-blank-space is battle-tested and used in production at Bloomberg, the idea of it is more important than its implementation.

ts-blank-space has a singular purpose and a limited scope. Many users will continue to be better served by existing tools with more comprehensive scopes. And who knows? If those tools find ways to make use of the ideas presented here, everyone wins! 😉