# Running typescript in node with near zero compilation cost
Typescript is a great step up from javascript, but it comes at a cost: compilation is slow. 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` (and `.ts` with `"type": "module"` in `package.json`) with near zero compilation cost:
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 --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 project](https://github.com/artemave/ts-swc-es-loader).
❯ 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 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](https://swc.rs/))
❯ time ./node_modules/.bin/ts-node 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](https://nodejs.org/dist/latest-v16.x/docs/api/esm.html#loaders) functionality.
#### Custom loader
Long story short, this is a loader that did the trick:
```javascript
// 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
}
}
// 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:
```javascript
// 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 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](https://direnv.net/):
export NODE_OPTIONS="--require ${PWD}/suppress-experimental-warnings.js --loader=${PWD}/loader.mjs"
Now I can simply run node as usual:
❯ node ./test.mts
### Bonus: tsx
With the following update, our loader can transpile `.tsx` just as well:
```diff
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
```
### 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.