When we talk about compling TypeScript projects there are usually two different contexts that are often mixed together: compiling a library vs compiling an application.

Those two contexts have slightly different constraints and need to be considered separately.

Today I'm going to focus on compiling a TypeScript library into a JavaScript package :)

The wishlist

In order to build a nice library that is suited for most projects, we want our package to be:

  • type-checked when consumed in a TypeScript project;
  • consumable in both commonjs and esmodule projects.

According to those wishes, we need to:

  • generate and package the declaration files (.d.ts);
  • be able to compile for different module targets.

Considered solutions

Today, we are going to benchmark the following solutions:

  • tsc - the native TypeScript compiler
  • esbuild - a full-fledged bundler and superfast compiler written in go
  • swc - a full-fledged bundler and superfast compiler written in rust
  • tsup - a TypeScript library builder based on esbuild

Side note: I have excluded tools that are only bundlers (webpack, rollup, ...) and babel which was superseded by swc and esbuild.

compilerd.ts generationmultiple module targets
esbuild
swc
tsc
tsup

By comparing features, there is already something that stands out: only tsup can generate declarations files (.d.ts) and target multiple package type out of the box.

This is not a showstopper, though!
Nothing is preventing us from running the compilation twice for different module targets.
And for the d.ts generation, we could rely on tsc --emitDeclarationOnly to generate them for us. (Both are actually what tsup is doing under the hood).
It's worth noting that those processes do not step on each other's toes, so we could run both compilation in parallel :)

Here would be the equivalent commands for all the tools we are going to evaluate:

esbuild

esbuild src/*.ts --format=esm --outdir=./lib/esm &
esbuild src/*.ts --format=cjs --outdir=./lib/cjs &
tsc --emitDeclarationOnly --outDir ./lib/types

tsup

tsup src/*.ts --format esm,cjs --dts -d=./lib

swc

swc -C module.type=es6 src/*.ts -d ./lib/esm &
swc -C module.type=commonjs src/*.ts -d ./lib/cjs &
tsc --emitDeclarationOnly --outDir ./lib/types

tsc

tsc --module esnext --outDir ./lib/esm &
tsc --module commonjs --outDir ./lib/cjs

Benchmark

This benchmark will be all but scientific!
I just tested compiling a library that has a single TypeScript file on Github CI. Every compiler is run 30 times, which mean we have very little samples.
This is just to give us a rough idea of how they could perform.
Please take this benchmark with a grain of salt!

(Here are the benchmark sources and the results on Github Actions)

compilerminmeanmax
esbuild1704ms1812ms2365ms
swc1794ms1855ms1908ms
tsup2001ms2034ms2085ms
tsc2160ms2215ms2420ms

Just to highlight how generating d.ts with tsc is costly, here is the same benchmark in which we skip generating declaration files:

compiler (no d.ts)minmeanmax
esbuild414ms427ms 500ms
swc537ms550ms570ms
tsup559ms570ms587ms
tsc2119ms2196ms2783ms

This is an order of magnitude different expect for tsc that seems to yield the same results whether it generates declaration files or not.

There is no obvious winner between swc, esbuild and tsup, but we can see that tsc is constantly outperformed.

Conclusion

One sad realization that I had while putting up together this benchmark is that no matter how fast the compiler is, it's always going to be at least as slow as tsc --emitDeclarationOnly.

My personnal benchark isn't really significant when it comes to performances comparisons... But we can clearly identify that tsc is the looser!

Now, with its built-in support for declaration files generation and multiple targets in my opinion tsup is the winner! It is tailored for building libraries!
Having only a single command to write to compile our libraries means it easier for maintenance and if, in the future, tsup figure out a way to generate d.ts files faster we will benefit from it by upgrading our local version.