Oct 04, 2020

Rapid TypeScript Development with esbuild and Estrella

With codebases growing steadily, so does the time spent waiting for pipelines of builds to finish, type checks to run, and in many development environments, services to be started and stopped. This classic approach to developing TypeScript services has led to much frustration over the years, as the size of projects only ever grew, but speed improvements couldn't hold up.

Until now, that is. With the introduction of esbuild, a Go-based build tool for TypeScript and JavaScript, transforming large JavaScript-like codebases is finally fast again, with benchmark results showing speed improvements of up to 100x compared to existing systems (think webpack, Rollup, Parcel)

To add onto this, a couple of days ago, another fantastic tool based on esbuild was released: Estrella offers the simplicity of a file watcher, running type checks and diagnostics for multiple projects at once while staying completely flexible how you design your build processes.

Let's compare a traditional TypeScript build setup and codebase, then introduce esbuild and Estrella to see how we can improve build times and our developer experience.

🐢 Classic TypeScript builds

Consider the following TypeScript configuration (this is only a snippet taken from a tsconfig.json), which starts out in the current directory, and will take care of type-checking and building all TypeScript files in the src directory, compiling to a regular ES2019 CommonJS output, which the Node.js runtime can spin up.

Due to the baseUrl being set, our codebase also allows for imports relative to the project root (e.g. import { xyz } from "src/some/path"; instead of import { xyz } from "../../some/path";).

{
  "include": [
    "./src"
  ]
  "compilerOptions": {
    "target": "ES2019",
    "module": "CommonJS",
    "outDir": "./dist",
    "rootDir": ".",
    "baseUrl": ".",
    "moduleResolution": "node",
  }
}

While we could just call it a day and always build the service, we'd like a more pleasant experience of being able to start our development environment once and restart the service automatically as we code.

This is why, in addition to our regular build script that simply invokes the TypeScript compiler, we also use tsc-watch to take care of watching over file changes and spawning a child process for our service, once compilation has completed. Let's quickly check out our package.json and the scripts we defined for building, starting, and launching our service in a local development environment.

{
  "scripts": {
    "start": "node dist/src/main.js",
    "build": "tsc --project tsconfig.json",
    "dev": "tsc-watch --project tsconfig.json --onSuccess \"yarn start\""
  }
}

Building this codebase (running yarn build) takes around 15s. Starting the development environment (yarn dev) takes 20s just to get started. Detecting a file change and restarting the service takes around 5s, which gets progressively worse when running in containers.

Adding in more files, some pre- and post-processing steps, and other tasks as part of the build pipeline will only make this worse, so I think we can continue with seeing how a similar setup based on esbuild and Estrella compares in terms of performance and the overall experience.

🐇 Going Faster with esbuild

After installing esbuild, let's get to the build output we need to run our service.

yarn esbuild \
  --outdir=dist/src \
  --platform=node \
  --target=es2019 \
  --format=cjs \
  src/**/*.ts

Running this will locate all TypeScript source files (using your shell's glob functionality) and compile those down to ES2019 with CommonJS module resolution enabled for the Node.js platform. It takes around 150ms to get our complete codebase built, though it should be noted, that this does not run type checks!

This is where our next change will come in handy, so let's continue with adding Estrella for setting up our complete build and development environment.

🌱 Getting the complete development lifecycle with Estrella

Estrella allows you to create fast and fully-featured build pipelines for your TypeScript projects, watching and building your source files using esbuild, running the the TypeScript compiler for type-checking, and allowing you to run your application once built, restarting gracefully when code changes underneath.

Once installed (yarn add -D estrella), we can create a build.js file that will continue our build script. It's worth noting that Estrella can be invoked as a CLI itself, but you'll often customize your setup in ways where scripting becomes the far more comfortable solution, so having a build script is the default.

#!/usr/bin/env node

const { build } = require('estrella');

build({
  entry: './src/bundle.ts',
  outfile: './dist/src/main.js',
  target: 'es2019',
  platform: 'node',
  format: 'cjs',
  bundle: true,
  tsconfig: './tsconfig.json',
  watch: true,
  run: true
});

After making the build.js file executable (chmod +x build.js), we can run it! Running in bundle mode, this will pull in all of our source code, resolve dependencies, watch our files for changes, and start the application once built.

🍁 Adding in external modules

When we plan on using external modules, esbuild will attempt to bundle them, which might not be the expected behaviour. We can disable the bundling altogether, which requires us to gather all files that should be built in advance, in our case, resolving the glob of TypeScript files does the job.

Without the bundle option enabled, using relative imports might result in Cannot find module '...' errors during execution. I've yet to understand how this can be fixed reliably. If you've got a tip, please let me know!

#!/usr/bin/env node

const { build } = require('estrella');
const globby = require('globby');

globby('./src/**/*.ts').then(sourceFiles => {
  build({
    entryPoints: sourceFiles,
    outdir: 'dist/src',
    target: 'es2019',
    platform: 'node',
    format: 'cjs',
    tsconfig: './tsconfig.json',
    watch: true,
    run: ['node', 'dist/src/main.js']
  });
});

Having to opt-out of the internal dependency resolution esbuild offers for tracking source file changes is a bummer, but I'm confident that those cases will be served by a solution as well.

With a bit of fiddling, we get a really fast toolbox for building, type-checking, and running our TypeScript applications. For smaller projects, the difference in speed might not be felt, but large codebases

🍧 Getting up and running quickly

If you want to check out a running example for both the bundled and non-bundled application with external dependencies, head over to the GitHub example.


Thanks for reading! If you've got any questions, suggestions, or feedback in general, don't hesitate to reach out on Twitter or by mail.