Running Typescript programs with babel-node aka how does babel register work

As a member of a frontend infrastructure team, (aka DivOps), I write a lot of Javascript tooling. I also really really like Typescript. In fact most of the tooling I write these days is written in Typescript.

The problem is, when the tools we work on are handed off to our end users, they have to either be compiled with tsc, or babel first. Generally we typecheck with tsc and transpile with the @babel/preset-typescript preset.

Sometimes though, that compilation step causes a delay, and we'd rather just run the code as is, Typescript, ES modules and all. That's where tools like @babel/node come in.

@babel/node allows you to "transpile" code from Typescript, or "ES Next" JavaScript with modules, or whatever to Javascript that can run in nodejs, and it does this at runtime as opposed to pre-transpiling.

Aka you can do stuff like this, and run Typescript files without "pre" compiling them.

babel-node -x .ts -- ./path/to/script.ts

So, how does @babel/node actually work?

npm i -D @babel/node

When you install this pacakage, it will add a babel-node command to your ./node_modules/.bin folder. This .bin folder is used for all CLI tools you install, things like webpack, babel-node, etc. The .bin then just symlinks the babel-node command to ./node_modules/@babel/node/bin/babel-node. This works because of the "bin" being set to "babel-node": "./bin/babel-node.js" right here.

Then, babel-node is simply a program with a shebang that tells the system to what interpreter to use for the program, in this case the /usr/bin/env command is what actually runs first, and makes sure that it can find node.

#!/usr/bin/env node

The babel-node program itself then just imports require("../lib/babel-node");

This is where we start getting into the actual runtime of @babel/node.

The lib/babel-node.js script is responsible for reading in any runtime arguments for v8 runtime stuff like --harmon or node env variables like --require.

Ultimately the lib/babel-node.js uses a program called kexec, a C++ program which swaps out a process at runtime, to actually run ./lib/_babel-node.js

./lib/_babel-node.js is where things really start to happen as this is really the meat of what @babel/node is actually doing. It uses a CLI helper utility called commander and sets it up with the following possible args.

You'll notice a few of those are similar to ones you'd use when working directly with node itself. Things like -e, -p, and -r are all flags that node itself also accepts, other flags like --inspect fall through to the node process that will ultimately get spun up. Also note, the --extensions flag requires ALL the extensions you're going to use, so if you know you're going to need multiple, make sure you do --extensions .js,.ts,.jsx,.tsx

Eventually after some args parsing, an internal Module.runMain function happens and runs your script by overwriting the arguments passed with process.argv.

process.argv = [
 '/path/to/node/v12.17.0/bin/node',
 '/path/to/node_modules/@babel/node/lib/_babel-node',
 ...args,
];

Then what happens in [this line](https://github.com/babel/babel/blob/5067edfdd95fc4c38cb5e60192eac5f7649fa229/packages/babel-node/src/_babel-node.js#L198) is, node will now run /path/to/_babel-node, and send it your actual runtime arguments.

So, that's how _babel-node is actually invoked, but how does it transpile Typescript to Javascript on the fly?

The answer there is @babel/register.

@babel/register

@babel/register is the special sauce of how @babel/node is able to actually read in Typescript, or ES Next Javascript, transpile it to commonjs, and run it on the fly.

Using @babel/register is one way the docs say to setup your code.

Basically instead of babel-node -- path/to/your/script.ts you can manually call the @babel/register module itself by importing it in your code and call it with node /path/to/your/script.ts, in the case of Typescript though

require("@babel/register")({
  extension: ['.ts', '.tsx', '.js', '.jsx']
});
const { main } = require("./src/cli");

main().catch((e) => console.error(e));

Keep in mind with register, you have to separate your entry point from your code because register has to run and add the require hooks to the NEXT files you'll load, aka you can't have import and require("@babel/register"); in the same file. Note to use Typescript and Typescript with React, you'll have to add the "extensions". The other arguments you can pass to register are the same as the regular babel options.

In terms of @babel/node, the register function gets called here and is passed the command line args, and some additional configuration for babel.

@babel/register uses a library called pirates to add a compile hook using some internal Module.__extensions magic to change the underlying Javascript loader in node. It does that here.

So basically every time a file gets require'd going forward in your program, it'll first run through the compile function, get compiled, and finally executed.

Running and debugging babel-node

There's a couple of different ways to run a script with babel-node, just like with node itself.

Passing it the name of a script will invoke the script just like it would with node.

babel-node -x .ts -- ./path/to/script.ts

👆note the -x .ts or --extensions .ts there to make it recognize the Typescript .ts extension as well as .js

You can also add the --inspect flag and in VS Code or using chrome://inspect, you can debug your command line script. In VS Code make sure you have on Auto Inspect, and you'll then be able to throw breakpoints into your code.

If you want to use the Chrome developer tools to debug your script, make sure you throw a debugger; statement somewhere in your code, then you can run...

babel-node -x .ts --inspect-brk -- ./path/to/script.ts

The --inspect-brk will force the process to pause at the very first line of execution, which actually in this case will pause in the _babel-node.js script since that's technically the first line of code that gets executed. Then you can visit chrome://inspect and find your node script in the list, and click inspect.

You'll then get dropped into a Chrome Debugger.

You can run babel-node with -e and execute code on the fly.

babel-node -e "console.log('oh hai');"

You can run a script in combo with -p and print the results...

babel-node -e "new Date().getTime()" -p
// 1607531838009

Lastly you can run babel-node by itself, to spin up a repl for playing around in node with an environment that will transpile code on the fly for you.

Gotchas

One thing in particular that has bit me many times, and partly is the reason why I am writing this post to begin with is,

When using babel register, call the register function before your code

As mentioned previously, make sure when using @babel/register, call the require("@babel/register") first, then require your actual code. Otherwise it won't work at all.

babel-node's command line ignore overrides the ignores in your babel.config.js

This one seems like a [bug](https://github.com/babel/babel/issues/11892#issuecomment-687845634), but basically, if your babel.config.js has any kind of ignore in it...

module.exports = {
  /* ... blah blah babel stuff */
  ignore: [function(filepath) {
    return filepath.includes('some-string-you-wanna-ignore');
  }]
}

You have to pass an empty ignore in your babel-node --ignore ' ' path/to/script.ts. It probably has to do with this line. Register is expecting ignore not to be undefined, and it's not checking that against the ignore from the babel.config.js.

babel-node will add your process.cwd() location to the --only param by default.

This might be fine in your codebase, but if you're expecting babel-node to be able to transpile code from outside of your cwd, it simply won't work because @babel/register . This goes back to the same line above. If no --only flag is passed at run time, babel will by default ignore anything outside your current working directory AND anything in $(cwd)/node_modules.

So, say you have a project structure like this.

packages/tools/my-awesome-tool/
packages/utils/
package.json

If you have a script in my-awesome-tool/scripts/cool-script.ts which imports from packages/utils and you invoke it FROM the directory, i.e. if you did cd packages/tools/my-awesome-tool, the scripts inpackages/utils will NOT get transpiled by default.

There are 2 ways around this. First, just run all your scripts from the root of your project. Second, you can pass --only /path/to/project. That way it won't ignore stuff.

Make sure to add the `--` thing when running a script

If you forget to add `--` in between your options i.e. babel-node -x .ts path/to/scripts.ts instead of babel-node -x .ts -- path/to/script.ts, then babel-node is going to try to parse through the options you passed to your OWN script. This is especially important when the script your calling takes it args.

For example...

babel-node -x .ts -- ./node_modules/.bin/build-storybook \
  -c packages/tools/storybook/src/config \
  -o packages/tools/storybook/dist/storybook

In this case, we're calling the build-storybook command with babel-node, and the -o flag is setting the output dir for storybook. If you forget the `--`, the -o param will actually be parsed as the --only flag for babel!

rootMode: upward

In a monorepo setup, or a setup where a babel config doesn't live in the root of your package, but rather up in a higher directory level...

./package.json
./babel.config.js
./packages/tools/storybook/package.json
./packages/tools/storybook/bin/cli.js

If the bin/cli.js has a call to @babel/register...

require('@babel/register')({
  extension: ['.ts', '.js'],
  rootMode: 'upward'
});
require('../src')();

In this case, if you want to cd packages/tools/storybook and then node bin/cli.js, the rootMode needs to be set so that babel will recursively search for a babel.config.js since there isn't one in the ./packages/tools/storybook working directory.

Conclusion

@babel/node is a nice convenient way to run scripts on the fly without pre-compiling them. Just be aware of some its nuances and you'll be able to build some amazing tools.

This post was written primarily because many of the things mentioned are things I've dealt with in real life, and I kept running into them over and over again, and finally decided it was time to write them down for myself! Hopefully it can help you too.