Introduction to custom Babel plugins

Joseph Mathew

Joseph Mathew

September 10, 2024

Introduction to custom Babel plugins

Babel is a tool that allows us to write modern JavaScript while ensuring it works across older browsers. Instead of generating binary code like traditional compilers, Babel performs source-to-source transformations.

For example, with modern JavaScript, we might use arrow functions like this:

1const add = (a, b) => a + b;

This code works in modern browsers but not in older ones, like Internet Explorer, which does not support arrow functions. Without Babel, we would need to use older syntax to ensure compatibility, which limits our use of modern features. Babel solves this problem by transforming the modern code into an older syntax that more browsers can handle. For example, Babel converts the arrow function code above into the standard function syntax shown below, which is compatible with all browsers.

1var add = function add(a, b) {
2  return a + b;
3};

With Babel, we can write modern JavaScript code without worrying about browser compatibility. Babel uses several presets and plugins to achieve this.

Plugins and Presets

Plugins are individual components that handle specific tasks in JavaScript code. For example, there are plugins for converting Arrow Functions to ES5 Functions, JSX to React functional calls and so on.

There are two main types of plugins:

  • Syntax Plugins: These plugins help Babel understand new syntax that it doesn’t recognize otherwise. For example, Babel doesn’t understand JSX syntax by default, so we need a plugin to enable this. @babel/plugin-syntax-jsx is a syntax plugin that allows Babel to recognize and parse JSX syntax.

  • Transformation Plugins: This type of plugins help Babel convert code written in modern syntax into format more widely supported by different browsers. For instance, @babel/plugin-transform-react-jsx is a transformation plugin that converts JSX into React functional calls.

Presets make things easier by bundling related plugins together. Instead of setting up each plugin separately, we can use a preset that includes all the necessary plugins. For instance, @babel/preset-react includes both @babel/plugin-syntax-jsx and @babel/plugin-transform-react-jsx, handling both JSX recognition and transformation in one go.

Plugin and Preset Order in Babel Configuration

Babel processes plugins in the order they are listed in the Babel configuration file. This is important because the output of one plugin can affect the input of another. For example, if we want to convert JSX to React functional calls, we need to ensure that the JSX syntax is recognized first. So, we should list the syntax plugin before the transformation plugin in the configuration file.

1{
2  "plugins": ["@babel/plugin-syntax-jsx", "@babel/plugin-transform-react-jsx"]
3}

Babel proccess presets in reverse order. This means that the last preset listed will be the first to be processed.

1{
2  "presets": ["@babel/preset-env", "@babel/preset-react"]
3}

For example, let's look at the configuration mentioned above. In this case @babel/preset-react preset will be applied first, followed by @babel/preset-env, ensuring that React specific transformations are handled before general environment compatibility transformations.

Custom Babel Plugin

While Babel offers a wide range of plugins and presets, it's true potential lies in the ability to create custom plugins tailored to our project's specific needs. In the final section of this blog, we will explore a use case we encountered at Neeto and how we solved it with a custom Babel plugin. For now, let's focus on creating a simple plugin. Before we begin, it's important to understand a key concept called an AST (Abstract Syntax Tree).

Abstract Syntax Tree (AST)

An AST is a tree representation of the structure of a program. It breaks down the code into its constituent parts and represents them as nodes in a tree. Each node in the tree represents a different part of the code, such as a variable declaration, a function call, or a loop. Babel uses ASTs to analyze and transform JavaScript code. For example, consider the following code:

1const sum = (x, y) => x + y;

This is how its AST looks:

AST Demo

In this example, the root node is the File node, which represents the entire code file. Within it, there are several nested nodes like, VariableDeclarator, ArrowFunctionExpression and so on, each dedicated to different parts of the code. As shown in the animation, hovering over each node highlights the corresponding code segment on the left-hand side. To explore and interact with both the code and its AST, the AST Explorer tool can be used.

Babel transpile stages

Now that we have a basic understanding of ASTs, let's see at how Babel works. It has three main stages:

  • Parse Stage: In this stage, the code that is passed to Babel as input is converted into an AST. This is done using the @babel/parser package.
  • Transform Stage: This is the stage where Babel traverses and modifies the AST using @babel/traverse package. This is where plugins come into play to apply specific transformations. It’s important to note that this is the only step we can influence. In the next section, we'll see how to create a custom Babel plugin.
  • Generate Stage: Once the AST has been modified, it needs to be converted back into code. This is done using @babel/generator package.

Creating a Babel Plugin

As mentioned in the previous section, Babel plugins are used to apply transformations to the AST, and this is the only step we can influence. So, let’s see how to create a simple Babel plugin. We will create a plugin that converts all == operators in the code to ===. This is a common transformation that developers often use to ensure strict equality checks in their code.

First, let's set up the project structure for our plugin.

  • Step 1: Create a new directory and initialize a new npm package for the plugin. Let's prefix the package name with babel-plugin-. This naming convention clearly indicates that the package is a Babel plugin, making it easier for others to identify its purpose.

    1mkdir babel-plugin-strict-equality
    2cd babel-plugin-strict-equality
    3npm init -y
  • Step 2: Create a file named index.js in the root of the plugin directory. This file will contain the logic for the plugin.

    1touch index.js

Now that we have set up the project structure, let's start writing the plugin code.

To transform the code, we need to traverse the AST generated from the parse stage. We can do this by creating a visitor object that defines the transformations to be applied to the AST. The visitor object contains methods that correspond to different types of nodes in the AST. When Babel encounters a node of a particular type, it calls the corresponding method in the visitor object to apply the transformation.

To understand this better, let's look at a code example that we want to transform:

1a == b;

We want to transform this code into:

1a === b;

First, let's examine the AST representation of the original code:

1{
2  "type": "File",
3  "program": {
4    "type": "Program",
5    "body": [
6      {
7        "type": "ExpressionStatement",
8        "expression": {
9          "type": "BinaryExpression",
10          "left": {
11            "type": "Identifier",
12            "name": "a"
13          },
14          "operator": "==",
15          "right": {
16            "type": "Identifier",
17            "name": "b"
18          }
19        }
20      }
21    ]
22  }
23}

Here, our goal is to modify the BinaryExpression node with the operator == to ===. To achieve this, we need to create a visitor method for the BinaryExpression. When Babel encounters a BinaryExpression node with the operator == in the AST, it will call our visitor method to apply the transformation.

So, let's write the code for our plugin. Open the index.js file we created earlier and add the following code:

1module.exports = function () {
2  return {
3    visitor: {
4      BinaryExpression(path) {
5        if (path.node.operator === "==") {
6          path.node.operator = "===";
7        }
8      },
9    },
10  };
11};

This plugin will traverse the AST, identify BinaryExpression nodes with the == operator, and replace them with ===, ensuring strict equality checks are used throughout the code.

Now that we have written the plugin code, let's see how to test it.

Adding tests for the plugin

To ensure that our plugin works as expected, we need to write tests. For this, we can use the babel-plugin-tester and jest packages. babel-plugin-tester is a utility that makes it easy to test Babel plugins, while jest is a popular testing framework for JavaScript. Let's see how we can add tests for our plugin.

  • Step 1: Install the required packages:

    1yarn add --dev babel-plugin-tester jest
  • Step 2: Create a folder named tests in the project directory. Inside this folder, create a test file named strict-equality.spec.js with the following content:

    1const pluginTester = require("babel-plugin-tester");
    2
    3const plugin = require("../index");
    4
    5pluginTester({
    6  plugin,
    7  tests: {
    8    "should convert == to ===": {
    9      code: "a == b;",
    10      output: "a === b;",
    11    },
    12    "should not modify == inside a string or comment": {
    13      code: `
    14        const str = "a == b";
    15        // comparison: a == b
    16      `,
    17      output: `
    18        const str = "a == b";
    19        // comparison: a == b
    20      `,
    21    },
    22  },
    23});

    In the code above, we are passing an object to the pluginTester function with two keys: plugin and tests. The plugin key specifies the Babel plugin we want to test, while the tests key contains an object defining our test cases. Each test case includes a code key with the input code and an output key with the expected result after the plugin is applied. The pluginTester function runs each test case, comparing the actual output with the expected output. If they match, the test passes; if they don’t, it fails.

  • Step 3: Now to run the tests, use the following command:

    1yarn jest

    This will run the tests and display the results in the terminal. If the test passes, it means our plugin is working as expected.

    jest-result

Testing the plugin in a real project using yalc

While pluginTester helps cover many edge cases, it’s challenging to anticipate all possible scenarios. For this, we need to run our rules in real projects and we will have to achieve this without publishing our package to the remote registry right away.

To achieve this, we can use the yalc package. yalc is a tool that allows us to work on the npm packages as if they were published, but without actually publishing them. It creates a symlink to our local package in the global npm registry, allowing us to install and use it in other projects. Let's see how we can use yalc to test our plugin.

  • Step 1: Install yalc globally.

    1yarn global add yalc
  • Step 2: Publish the plugin using yalc by running the following command in the plugin directory.

    1yalc publish

    This creates a symlink to the local package in the global npm registry.

  • Step 3: Install the plugin in the project using yalc by running the following command in project directory.

    1yalc add babel-plugin-strict-equality

    This will add the plugin to the project as if it were installed from the npm registry.

  • Step 4: Include the plugin in the Babel configuration.

    1{
    2  "plugins": ["babel-plugin-strict-equality"]
    3}

After testing the plugin in a real project and being satisfied with the results, it can be published to the npm registry.

Creating a Babel preset

If we have multiple plugins that we want to bundle together, we can create a Babel preset. As mentioned earlier, presets make it easier to configure Babel by bundling related plugins together. Here is an example of how we can create a Babel preset that includes the babel-plugin-strict-equality plugin:

  • Step 1: Create a new directory for the preset and initialize a new npm package for the preset. Let's prefix the package name with babel-preset- to indicate that it is a Babel preset.

    1mkdir babel-preset-strict-equality
    2cd babel-preset-strict-equality
    3npm init -y
  • Step 2: Create a folder named plugins in the root of the preset directory. This folder will contain the plugins that we want to include in the preset. In our case, we have only one plugin, so create a file named strict-equality.js inside the plugins folder and add our plugin code to it.

    1module.exports = function () {
    2  return {
    3    visitor: {
    4      BinaryExpression(path) {
    5        if (path.node.operator === "==") {
    6          path.node.operator = "===";
    7        }
    8      },
    9    },
    10  };
    11};
  • Step 3: Create a file named index.js in the root of the preset directory. This file will contain the logic for bundling the plugins together.

    1const strictEquality = require("./plugins/strict-equality.js");
    2
    3module.exports = function () {
    4  return {
    5    plugins: [strictEquality],
    6  };
    7};

    Here we are returning an object with a plugins key that contains an array of plugins to be included in the preset. If there are multiple plugins, we can list them all in this array. We can now publish this preset to the npm registry and use it in our Babel configuration.

  • Step 4: To use the preset in the Babel configuration, include it like this:

    1{
    2  "presets": ["babel-preset-strict-equality"]
    3}

Here we have created a simple Babel plugin and preset. This is just the tip of the iceberg when it comes to creating custom Babel plugins. We can create plugins to handle a wide range of tasks, from optimizing code to adding new features. To know more about the functions and methods available in Babel, you can refer to the Babel handbook. In the next section, we will see the use case we encountered at Neeto and how we solved it using a custom Babel plugin.

Motivation behind babel-preset-neeto

At Neeto, we build our own custom plugins and presets to streamline and simplify our development workflow. Let's look into one of the specific plugins we've designed and implemented and see how it enhances our development process.

We use Zustand for global state management. Zustand provides shallow function, which allows us to to construct a single object with multiple state-picks inside. This helps prevent unnecessary re-renders by using shallow equality to check if the selected state values have changed. Let's consider the following example:

1import { shallow } from "zustand/shallow";
2
3const { id, name } = useSessionStore(
4  store => ({
5    id: store[sessionId]?.user.id,
6    name: store[sessionId]?.user.name,
7  }),
8  shallow
9);

In this example, the component only re-renders if id or name changes. If any other state in the store changes, the component will not re-render. This significantly improves performance. To know how this works, you can refer to this blog post. However, when dealing with many state selections, the code can become verbose and repetitive. For example consider the following code:

1import { shallow } from "zustand/shallow";
2
3const {
4  id,
5  name,
6  email,
7  notifications,
8  theme,
9  language,
10  isAuthenticated,
11  apiToken,
12  appVersion,
13  darkModeEnabled,
14} = useSessionStore(
15  store => ({
16    id: store[sessionId]?.user.id,
17    name: store[sessionId]?.user.name,
18    email: store[sessionId]?.user.email,
19    notifications: store[sessionId]?.user.notifications,
20    theme: store[sessionId]?.user.theme,
21    language: store[sessionId]?.user.language,
22    isAuthenticated: store[sessionId]?.user.isAuthenticated,
23    apiToken: store[sessionId]?.user.apiToken,
24    appVersion: store[sessionId]?.user.appVersion,
25    darkModeEnabled: store[sessionId]?.user.darkModeEnabled,
26  }),
27  shallow
28);

This is really repetitive, for each state, we need to add a line on left and right side of the code. Then we thought, what if we can infer the right side from the left side of the code. This will make the code more readable and less error-prone. But this is not possible with the custom functions or hooks because they operate at runtime and cannot modify the structure of the code during transpilation. This is where custom Babel plugins come into play. We have built a Zustand pick transformer capable of generating the boilerplate at the time of transpiling the code.

If this plugin is added to the babel configuration, we can rewrite the previous example as:

1const {
2  id,
3  name,
4  email,
5  notifications,
6  theme,
7  language,
8  isAuthenticated,
9  apiToken,
10  appVersion,
11  darkModeEnabled,
12} = useSessionStore.pick([sessionId, "user"]);

The array inside pick() is the path of the nested object to be accessed. You might also notice that we don't have the import { shallow } from "zustand/shallow" statement in this piece of code. We don't need that. The plugin will automatically add it for us at the time of transpiling.

The plugin detects that id, name, email, and other properties are to be accessed from the store[sessionId].user in useSessionStore and it generate code for it. It will also add optional chaining for all the nested properties automatically to make the picks free from null-pointer errors.

For more information about the plugin, refer to the documentation. To see the implementation of the plugin, check the source code

We've open-sourced this preset package so that it's accessible for use in your projects as well. To include this preset, install it using the following command:

1yarn add -D @bigbinary/babel-preset-neeto

And then include it in the Babel configuration:

1{
2  "presets": ["@bigbinary/neeto"]
3}

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.