Babel macros

Babel macros are a way to apply code transformations without having to install a new plugin for each transformation. They are implemented in a clever way and can be very useful. And best of all, they work out of the box with Create React App, GatsbyJS, and NextJS. This post will dive into what they are, how to use them, and how to make them using TypeScript.

Why?

Regular Babel plugins are great, but come with a few downsides.

Babel macros to the rescue!

What?

Babel macros are possible because of the babel-plugin-macros library (which is itself a Babel plugin).

This library enables other libraries to create macros by

  1. treating all imports that end in .macro or /macro as a macro.
  2. providing a createMacro function that gives access to Babel.

But TypeScript?

If you use TypeScript you can still benefit from the power of macros. Instead of using the TypeScript compiler (tsc) to compile your code, you can use Babel with the TypeScript preset instead. This is what CRA, Gatsby, and Next are already configured to do.

Usage

If you are not using CRA, Gatsby, or Next and have full control over the Babel pipeline, you can enable using Babel macros in your project by adding the dependency (if you are using these tools, then you don’t have to install or configure anything).

npm i --save-dev babel-plugin-macros

and adding it to your Babel config

// .babelrc
{
  "plugins": ["macros"]
}

Use a macro just by importing it like any other JavaScript dependency. For example, the count.macro.

import { lines } from "count.macro";

console.log(`This file has ${lines} lines`);

Development

Let’s make a macro! The macro will place the contents of a file into a variable at compile time. A user will be able to use it like this:

import file from "my.macro";

const contents = file("my-file.txt");

This section assumes you have some knowledge of ASTs and how to manipulate them with Babel. The plugin handbook and AST explorer are great resources for learning or familiarising yourself with these concepts.

Caveats

There are a few caveats you have to keep in mind when developing a macro.

TypeScript

We are going to develop it in TypeScript. If you are not using TypeScript you can still follow along, just remove the type definitions.

Setup

First we need to install our dependencies. We will use yarn for this.

yarn add babel-plugin-macros
yarn add --dev typescript jest ts-jest @types/babel__core @types/node @babel/cli @babel/preset-typescript

If using TypeScript, create a tsconfig file. Something like this. Also, if you are using TypeScript you will want to add some definitions for babel-plugin-macros (the ones on DefinitelyTyped are a bit outdated at the time of writing this. There is a PR open to update them). You can grab the updated ones from here.

Next we can add some npm/yarn scripts.

"scripts": {
  "build": "tsc -p .",
  "test": "jest",
  "compile": "yarn run build && babel --plugins=babel-plugin-macros --presets=@babel/preset-typescript"
}

The compile script will first compile our macro, then compile an example file with Babel. This is really helpful when testing and developing.

Finally, we can create our macro and an example to test with.

// src/index.macro.ts

// TODO
// examples/test.ts
const x = 1;

Run our example with

yarn compile examples/test.ts

You should see the contents of the file output to the console. Awesome! We are now setup.

Macro Definition

A macro is created by calling the createMacro function from babel-plugin-macros. The macro must be the default export.

import { createMacro } from "babel-plugin-macros";

const myMacro = createMacro(/* ... */);
export default myMacro;

The first argument to createMacro is a function that takes an single parameter: an object with a references, state, and Babel field. This is the type definition:

export type References = {
  [key: string]: Babel.NodePath[];
};

export interface MacroParams {
  references: { default: References } & References;
  state: any;
  babel: typeof Babel;
}

export type MacroHandler = (params: MacroParams) => void;

export function createMacro(handler: MacroHandler, options?: Options);

references

This is the main thing our macro will use to manipulate the AST. References is an object that contains Babel NodePaths for all the places your macro was imported. For example, if I have the following source.

import foo, { bar } from "my.macro";

foo(); // line 3
foo(); // line 4

const x = bar + 1; // line 6

References will be:

{
  default: [/* NodePath line 3 */, /* NodePath line 4 */],
  bar: [/* NodePath line 6 */]
}

This is important to note and tripped me up when I was first learning about macros.

The default export of your macro file is always called with references to all places it was imported and used. Even if the user imports a named export from your macro, the default export will still be called.

Since references contain Babel NodePaths, they allow use to manipulate the ASTs. This is what we will use below.

state

The state of the file being traverse. This is the second argument received in a visitor function of a normal Babel plugin. It will look something like:

{
  "file": {
    "declarations": {},
    "path": NodePath,
    "ast": Node,
    "code": string,
    "opts": {
      /* ... */
    },
    "scope": Scope
    // ...
  },
  "filename": string
  // ...
}

babel

Same as require("babel-core"). We can use it to get Babel types.


const myMacro = ({ references, state, babel }) => {
  const t = babel.types;
};

Our Macro

With the above knowledge, here is our macro.

import { createMacro, MacroHandler } from "babel-plugin-macros";
import * as fs from "fs";
import * as path from "path";

const myMacro: MacroHandler = ({ references, state, babel }) => {
  const t = babel.types;
  const currentFilename = state.file.opts.filename;

  references.default.map(({ parentPath }) => {
    if (
      t.isCallExpression(parentPath.node) &&
      t.isStringLiteral(parentPath.node.arguments[0])
    ) {
      const filename = path.resolve(
        path.dirname(currentFilename),
        parentPath.node.arguments[0].value
      );
      const contents = fs.readFileSync(filename, "utf8");
      parentPath.replaceWith(t.stringLiteral(contents));
    }
  });
};

export default createMacro(myMacro) as (filename: string) => string;

You can see I had to manually case the type to (filename: string) => string. This way TypeScript users will get type checking and autocomplete when using this macro. If my macro was a named export instead, I could type with:

export const file = null as (filename: string) => string;

Finally, we can change the example to:

// examples/test.ts
import file from "../lib/index.macro";

const contents = file("test.ts");

The tranpsiled code will be,

const contents = "import file from \"../lib/index.macro\";\n\nconst contents = file(\"test.ts\");\n";

Conclusion

Babel macros are a great way to add compile time transformations to your project. They are low effort to setup and already work if you are using CRA, Gatsby, or Next. There are already a lot of macros available (check out awesome-babel-macros), but I believe there is still a lot of untapped potential. They are also fairly easy to create, especially if you already have some experience writing Babel plugins.