By Artem Avetisyan on January 02, 2023 • 6 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
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.
all file names hereinafter refer to this repo.
❯ time node js/test.mjs
0.06s user 0.02s system 100% cpu 0.079 total
❯ 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.
❯ 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.
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
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"
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
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.
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.