ARCHIVED

Node + SWC make a lightning fast typescript runtime

By Artem Avetisyan on January 02, 20236 min read

This post covers speeding up the startup part of runtime. The other part - how fast the code is actually running - remains constant (and fast), since it’s javascript being run in all cases.

Typescript is a great, but it comes at a cost. In particular, compilation to javascript is too slow to happen at runtime and so a separate build step is required. Or so it used to be. Indeed, using off-the-shelf tools, it does look that way, but digging a bit deeper yielded a pretty exciting result.

TLDR: you can run .mts (or .ts with "type": "module" in package.json) with near zero compilation cost:

npm install @swc/core
curl https://raw.githubusercontent.com/artemave/ts-swc-es-loader/main/loader.mjs -O
curl https://raw.githubusercontent.com/artemave/ts-swc-es-loader/main/suppress-experimental-warnings.js -O

node --require ./suppress-experimental-warnings.js --enable-source-maps --loader ./loader.mjs my-script.ts

How I got there

Let’s compile some typescript using different tools and then compare the numbers. As an extra requirement, all compilation options must produce esm javascript (because esm module can import both esm + commonjs modules, but commonjs one can’t require esm).

To get faster compilation, we also skip type checking at compile time. Type checking can be run separately as a linter. As an added bonus, this allows to quickly spike ideas without having to fix up all type errors.

Vanilla node (baseline)

all file names hereinafter refer to this repo.

❯ time node js/test.mjs
0.06s user 0.02s system 100% cpu 0.079 total

ts-node (transpile only)

❯ time ./node_modules/.bin/ts-node -P tsconfig-ts-node.json ts/ts-node-test.mts
2.28s user 0.24s system 211% cpu 1.195 total

This is hopelessly slow.

Note that bare ts-node won’t cope with .mts imports, so they need to have .mjs extension.

ts-node (via swc)

❯ time ./node_modules/.bin/ts-node -P tsconfig-ts-node-swc.json ts/test.mts
0.49s user 0.14s system 116% cpu 0.421 total

This is much faster and, unlike bare ts-node, it can import .mts.

But it’s still too slow. swc is phenomenally fast and so the bulk of the above time is actually spent importing typescript libraries. This is how fast bare swc can get:

❯ time ./node_modules/.bin/swc ts/test.ts
0.18s user 0.04s system 118% cpu 0.135 total

Note .ts file extension. For some reason, swc wasn’t picking up top level .tsm for me.

The above command simply outputs transpiled code to stdout. We need to plug that into node, but without ts-node. For this we can employ node’s custom loader functionality.

Custom loader

Long story short, this is a loader that did the trick:

// loader.mjs
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { transformSync } from '@swc/core'

const extensionsRegex = /\.m?ts$/

export async function load(url, context, nextLoad) {
  if (extensionsRegex.test(url)) {
    const rawSource = readFileSync(fileURLToPath(url), 'utf-8')

    const { code } = transformSync(rawSource, {
      filename: url,
      jsc: {
        target: "es2018",
        parser: {
          syntax: "typescript",
          dynamicImport: true
        },
      },
      module: {
        type: 'es6'
      },
      sourceMaps: 'inline'
    })

    return {
      format: 'module',
      shortCircuit: true,
      source: code
    }
  }

  // Assume files without extension (e.g. tsc) are 'commonjs'
  context.format ||= 'commonjs'

  // Let Node.js handle all other URLs.
  return nextLoad(url, context)
}

And the result is 4 times faster than the faster ts-node:

❯ node --loader ./loader.mjs ts/test.mts
0.09s user 0.04s system 109% cpu 0.115 total

Node loaders is an experimental feature and as such produces the following warning:

(node:3045614) ExperimentalWarning: Custom ESM Loaders is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

That’s very informative but I don’t want to see it every time node is invoked. There appears to be no switch to turn it off, but with a bit of code we can make it go away:

// suppress-experimental-warnings.js
const defaultEmit = process.emit

process.emit = function (...args) {
  if (args[1].name === 'ExperimentalWarning') {
    return undefined
  }

  return defaultEmit.call(this, ...args)
}

Add this to command line arguments and voila:

❯ node --require ./suppress-experimental-warnings.js --loader ./loader.mjs ts/test.mts

Finally, adding those to all places where node is invoked (e.g. mocha) is a bit tedious, so you might prefer to have those switches in an environment variable (via dotenv or the like). For example, with this in my .envrc:

export NODE_OPTIONS="--require ${PWD}/suppress-experimental-warnings.js --loader=${PWD}/loader.mjs"

Now I can simply run node as usual:

❯ node ./ts/test.mts

Source maps

SWC is already configured to produce source maps, but for them to actually affect stack traces we need to specify --enable-source-maps node option. In the end, NODE_OPTIONS should look like this:

export NODE_OPTIONS="--require ${PWD}/suppress-experimental-warnings.js --loader=${PWD}/loader.mjs --enable-source-maps"

Bonus: tsx

With the following update, our loader can transpile .tsx just as well:

5c5
< const extensionsRegex = /\.m?ts$/
---
> const extensionsRegex = /\.m?ts$|\.tsx$/
17a18,22
>         },
>         transform: {
>           react: {
>             runtime: 'automatic',
>           },

Let’s see it in action:

❯ time node --loader ./loader-tsx.mjs ts/test-tsx.mts
{ banana: 'typescript' } {
  '$$typeof': Symbol(react.element),
  type: 'div',
  key: null,
  ref: null,
  props: { children: 'Hello' },
  _owner: null,
  _store: {}
} Foo bar
node --loader ./loader-tsx.mjs ts/test-tsx.mts  0.13s user 0.05s system 114% cpu 0.160 total

Type checking

The code compiles and actually works, but try to run npx tsc and it’s all over the place. This is an unfortunate side effect of using effectively two different typescript configurations - one for type checking with tsc and another one for compiling in the loader.mjs. So now we need to tweak tsconfig.json to marry up with the loader’s one. Good news is, starting from typescript 5 (not yet released as of this writing), this is not hard:

{
  "compilerOptions": {
    "noEmit": true, // disable compilation
    "module": "node16", // assume imports are esm
    // "jsx": "react-jsx", // if .tsx is used
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true
  }
}

It’s the last two settings - added in typescript 5 - that allow tsc to understand imports with .ts/.tsm/.tsx extensions.

It scales

I plugged this loader into a mid sized javascript project (30k loc) and it seemed to cope remarkably well. Time increase for a random unit test (the one that doesn’t import a lot of modules) stayed comfortably within 100ms. A god integration test (loads A LOT of the project) increased by about 200ms. Those are totally unscientific numbers, but it’s a promising start.


Discuss this post on Reddit.