Skip to main content

Zero-config code transformation with babel-plugin-macros

· 5 min read

Babel started out as a transpiler to let you write the latest version of the ECMAScript specification but ship to environments that don't implement those features yet. But it has become much more than that. "Compilers are the New Frameworks" says Tom Dale and I could not agree more. We're seeing more and more compile-time optimizations for libraries and frameworks. I'm not talking about syntax extensions to the language, but simple code transformations that enable patterns that would be difficult to accomplish otherwise.

One of my favorite things about compiler plugins is that you can use them to optimize the user experience and developer experience at the same time. (Read more about "How writing custom Babel & ESLint plugins can increase productivity & improve user experience").

I have a few problems with Babel plugins though:

  1. They can lead to confusion because when looking at code in a project, you might not know that there's a plugin transforming that code.
  2. They have to be globally configured or configured out-of-band (in a .babelrc or webpack config).
  3. They can conflict in very confusing ways due to the fact that all babel plugins run simultaneously (on a single walk of Babel's AST).

These problems could be solved if we could import Babel plugins and apply them directly to our code. This would mean the transformation is more explicit, we wouldn't need to add them to configuration, and ordering can happen in the order the plugins are imported. Wouldn't that be cool!?!?

Introducing babel-plugin-macros 🎣

Guess what! A tool like this exists! babel-plugin-macros is a new Babel plugin that allows you to do exactly what we're talking about. It's a "new" approach to code transformation. It enables you to have zero-config, importable code transformations. The idea came from Sunil Pai and caught my attention in this create-react-app issue.

So what does it look like? Whelp! There are already a few babel-plugin-macros packages out there you can try today!

Here's a real-world example of using preval.macro to inline an SVG in a universal application built with Next.js:

JavaScript
// search.js
// this file runs in the browser
import preval from 'preval.macro'
import glamorous from 'glamorous'

const base64SearchSVG = preval.require('./search-svg')
// this will be transpiled to something like:
// const base64SearchSVG = 'PD94bWwgdmVyc2lv...etc...')

const SearchBox = glamorous.input('algolia_searchbox', props => ({
backgroundImage: `url("data:image/svg+xml;base64,${base64SearchSVG}")`,
// ...
}))


// search-svg.js
// this file runs at build-time only
// because it's required using preval.require function, which is a macro!
const fs = require('fs')
const path = require('path')

const svgPath = path.join(__dirname, 'svgs/search.svg')
const svgString = fs.readFileSync(svgPath, 'utf8')
const base64String = new Buffer(svgString).toString('base64')

module.exports = base64String

What's cool about this? Well, the alternative would look exactly like the example above except:

  1. It's less explicit because there would be no import preval from 'preval.macro' in the source code.
  2. Have to add babel-plugin-preval to your babel configuration.
  3. Need to update your ESLint config to allow for the preval variable as a global.
  4. If you misconfigured babel-plugin-preval you'd get a cryptic runtime error like: Uncaught ReferenceError: preval is not defined.

By using preval.macro with babel-plugin-macros, we don't have any of those problems because:

  1. The import is there and used explicitly.
  2. babel-plugin-macros needs to be added to your config, but only once, then you can use all the macros you'd like (even local macros!)
  3. No need to update ESLint config because it's explicit.
  4. If you misconfigure babel-plugin-macros then you'll get a much more friendly compile time error message that indicates what the actual problem is pointing you to documentation.

So what is it really? The TL;DR is that babel-plugin-macros is a simpler way to write and use Babel transforms.

There are already several published babel-plugin-macros you can use, including preval.macro, codegen.macro, idx.macro, emotion/macro, tagged-translations/macro, babel-plugin-console/scope.macro, and glamor 🔜.

Another example

babel-plugin-macros is a way to have no config for non-syntax babel plugins. So many existing babel plugins could be implemented as a macro. Here's another example of babel-plugin-console which exposes a macro version of itself:

JavaScript
import scope from 'babel-plugin-console/scope.macro'

function add100(a) {
const oneHundred = 100
scope('Add 100 to another number')
return add(a, oneHundred)
}

function add(a, b) {
return a + b;
}

Now, when that code is run, the scope function does some pretty nifty things:

Browser:

Browser console scoping add100

Node:

Node console scoping add100

Cool right? And using it is just like using any other dependency, except it has all the benefits mentioned above.

Conclusion

I think we've only begun to scratch the surface of what babel-plugin-macros can do. I'm hoping that we can land it in create-react-app so folks using create-react-app can have even more power with zero configuration. I'm really excited to see more Babel plugins expose a macro in addition to the existing plugin functionality they already have. I can't wait to see folks create macros that are specific to their project needs.

Creating a macros is even easier than a regular Babel plugin, but it does require a bit of knowledge around ASTs and Babel. If this is new to you, there are a, few, resources for you 😀

Good luck to you all! 👋

P.S. I should mention that language macros are not a new concept at all. Being able to teach a language new tricks has been around for a very long time. In fact, there's already such a tool for JavaScript and even one implemented as a Babel plugin already. babel-plugin-macros takes a slightly different approach however. While macros have often been associated with defining new syntax for a language, that's not the goal of babel-plugin-macros at all. In the case of babel-plugin-macros it's more about code transformations.