When we talk about compiling 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
commonjsandesmoduleprojects.
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.
| compiler | d.ts generation | multiple 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/typestsup
tsup src/*.ts --format esm,cjs --dts -d=./libswc
swc -C module.type=es6 src/*.ts -d ./lib/esm &
swc -C module.type=commonjs src/*.ts -d ./lib/cjs &
tsc --emitDeclarationOnly --outDir ./lib/typestsc
tsc --module esnext --outDir ./lib/esm &
tsc --module commonjs --outDir ./lib/cjsBenchmark
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)
| compiler | min | mean | max |
|---|---|---|---|
| esbuild | 1704ms | 1812ms | 2365ms |
| swc | 1794ms | 1855ms | 1908ms |
| tsup | 2001ms | 2034ms | 2085ms |
| tsc | 2160ms | 2215ms | 2420ms |
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) | min | mean | max |
|---|---|---|---|
| esbuild | 414ms | 427ms | 500ms |
| swc | 537ms | 550ms | 570ms |
| tsup | 559ms | 570ms | 587ms |
| tsc | 2119ms | 2196ms | 2783ms |
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 personal benchmark isn't really significant when it comes to performances comparisons... But we can clearly identify that tsc is the loser!
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.