Back

tnode: simple scripting with Typescript

4 minute read
Arnaud Barré

Arnaud Barré

Front-end engineer

Martin Raison

Martin Raison

CTO

Bash and Python are the most popular scripting languages outside the browser. What if you could use Typescript instead?

Solution 1: Deno

For most use cases, Deno is clearly the most powerful option. It is widely used and has no dependency on Node.js. It also comes with many features, such as dependency resolution via arbitrary URLs, and a strong permission model. Using it on macOS is as simple as:

brew install deno
deno run myscript.ts

Keep in mind that:

  • you need to whitelist permissions explicitly. For many use cases that is an awesome feature. For quick scripting however, this can easily get in the way. This also forces you to jump through a few hoops when creating self-contained scripts with the shebang
  • at the time of writing, the Node.js compatibility mode is not stable, and most importantly, does not support TypeScript yet

Solution 2: ts-node

ts-node uses a very different strategy: it transpiles Typescript using tsc, and then directly runs the resulting code with Node.js. This ensures full compatibility with Node.js (unlike Deno), as well as full configurability via tsconfig.json.

You can get started with

npm install -g ts-node
ts-node script.ts

Adding #!/usr/bin/env ts-node at the beginning of your script allows you to run it with ./script.ts.

Solution 3: tnode

For simpler needs, tnode may be a better option. Here is how it works:

npm install -g @nabla/tnode
tnode script.ts

Same as ts-node, add #!/usr/bin/env tnode to your script to run it with ./script.ts.

Tnode requires Node.js 16+ and is fully compatible with it. However, it does not perform type checking. This may sound like a big deal, but in practice your IDE already does that. For informal scripting we found it to be a great compromise.

The main conceptual difference with ts-node is that tnode uses esbuild instead of tsc to transpile Typescript. This means it can start running the script much faster. Here is an example with a simple "hello world" script:

$ hyperfine 'tnode script.ts' 'ts-node script.ts'
Benchmark 1: tnode script.ts
Time (mean ± σ): 123.1 ms ± 6.5 ms [User: 105.5 ms, System: 22.0 ms]
Range (min … max): 116.4 ms … 139.9 ms 22 runs
Benchmark 2: ts-node script.ts
Time (mean ± σ): 870.0 ms ± 12.3 ms [User: 1424.7 ms, System: 85.7 ms]
Range (min … max): 855.8 ms … 895.7 ms 10 runs
Summary
'tnode script.ts' ran
7.07 ± 0.39 times faster than 'ts-node script.ts'

Under the hood

The cool thing about tnode is its extreme simplicity. You can easily fork it and adapt it to your needs! In fact, we'll put the whole code in this post.

First, we define a transformation that converts Typescript to Javascript using esbuild:

// transform.js
const { transformSync } = require("esbuild");
module.exports = (src, filename) => {
const { code, warnings } = transformSync(src, {
loader: "ts",
target: "node16",
format: "cjs",
sourcemap: "inline",
sourcefile: filename,
});
for (const warning of warnings) console.log(warning.location, warning.text);
return code;
};

This is a nice example of how you can harness the power of esbuild directly, outside of a toolchain like Vite or Snowpack.

We then use this transform to convert Typescript files (the main script + any imported files) on the fly before running them:

// tnode.js
#!/usr/bin/env node
const fs = require("fs");
const Module = require("module");
const { resolve } = require("path");
const transform = require("./transform");
if (process.setSourceMapsEnabled) {
process.setSourceMapsEnabled(true);
} else {
console.warn("Use node >= 16.6 to get source maps support");
}
Module._extensions[".ts"] = (mod, filename) => {
mod._compile(
transform(fs.readFileSync(filename, { encoding: "utf-8" }), filename),
filename
);
};
process.argv.splice(1, 1);
process.argv[1] = resolve(process.argv[1]);
Module.runMain();

There's a bit of dark magic in there, as we manipulate the internals of the Module object directly to add support for Typescript files.

Notice how Node.js 16.6+ unlocks experimental source map support. This means if your script runs into an error, you will see a more useful stacktrace with correct line numbers. Neat!

Which one to use?

We clearly recommend Deno or ts-node for:

  • production use cases
  • complex scripts
  • long-term robustness
  • configurability
  • REPL
  • systematic type checking

Deno is more portable (single executable), while ts-node is fully compatible with Node.js.

Tnode is handy when you need:

  • low-latency (for a more "shell-like" scripting experience)
  • full Node.js compatibility
  • simplicity

You can find the tnode repo here: https://github.com/nabla/tnode.

Happy typescripting!

Nabla is building a digital healthcare platform that is made affordable and scalable by machine learning. Come join us!