ESLint.png

I recently wanted to add a custom ESLint rule to my JavaScript codebase. Of course my initial reaction was to google it, and I found lots of helpful articles explaining how to add a custom ESLint package to the project, and import its rules (and even publish it online). I followed this tutorial to do this, but found I was a bit confused when it came to actually writing the rule. With some help from a colleague I managed to write a couple of rules to enforce in my codebase, which I will use to demonstrate the process of creating an ESLint rule.

Step 1 - Create Code Examples

To start with, quantify the aim of the rule you want to write, and then write some code examples which would pass or fail the rule you want to write.

The Aim

In my project, we use AG Grids to display tables of data with functionality such as filtering and row-grouping. In order to use the grid, you define a list of column definitions to display. A basic column definition declares the properties headerName and field. headerName is the name of the column, field is the property on the model object to pull the data for a cell in that column. In some situations, instead of a field property you use a valueGetter property to compute the value for the cell.

In order for some features of AG Grids to work, each column must have a unique ID. By default, the field property will be used for the column ID. However, when using a valueGetter instead of a field property, you should provide a colId property for the column ID. This unique ID is what I wanted to enforce using a linting rule.

The Code

// Valid
const columnDefinitions = [
  {
    headerName: 'Example',
    field: 'foo',
  },
  {
    headerName: 'Example',
    valueGetter: () => 'bar',
    colId: 'bar',
  },
]
// Invalid
const columnDefinitions = [
  {
    headerName: 'Example',
    field: 'foo',
  },
  {
    headerName: 'Example',
    valueGetter: () => 'foo',
  },
]

Step 2 - Explore Your AST

Linting rules are applied on what’s called an Abstract Syntax Tree (AST). The code you write is analysed and broken down into a tree of nodes with different properties such as type. This provides you with the information you need to work out whether the code triggers your rule, whether it passes or fails, and the metadata to feed an error back to the developer, such as line and column positions of the offending code.

AST Explorer

I found it very helpful to use https://astexplorer.net/ while writing my linting rules. Navigate to the website, hover over the Transform option at the top and select ESLint. The screen should now have 4 panes - in the top left you can write your example code from step 1. In the top right, it shows you the generated AST for that code. In the bottom left you can write your linting rule, and in the bottom right it will show the output - whether the provided code passed or failed the rule, and what the output was if it failed.

I will walk through the AST for the example of valid code I provided above for my rule. I would recommend reading this part of the post alongside the website as I describe the AST generated by this code.

Paste the passing code in the top left of ast-explorer, and take a look at the generated AST in the top right. At the top level of every tree will be a Program node with a body property. The body is an array of nodes which will vary depending on your code.

For my code example which passes the rule, there is one node which is of the type VariableDeclaration. Hovering over a node will show you which part of the code it refers to - in this case all of it.

Continuing down the tree, a VariableDeclaration has a property declarations, which is an array. An example of when this might contain multiple nodes would be if you were using the syntax x, y = 1;. In my case this has only one node inside it called a VariableDeclarator. This has two properties - the id, which here is an Identifier node type with the name "columnDefinitions", and the init, which here is an ArrayExpression node.

Continuing down the tree, an ArrayExpression has a property elements, which is an array of the nodes inside the array. In my case, there are two ObjectDeclaration nodes for the two objects in the array. An ObjectDeclaration node in turn has a property properties which is an array of the properties declared on the object, which are all Property nodes in my case.

The first ObjectDeclaration node has two Property nodes. A Property node has two properties, key and value matching the JavaScript terminology. In my example, both Property nodes have a key which is an Identifier node. Each Identifier node has a name property, with values “headerName” and “field” respectively. Both Property nodes also have a value which is a Literal node. Each Literal node has a value property, with values “Example” and “foo” respectively.

The second ObjectDeclaration node has three Property nodes. headerName and colId follow the same pattern described above, but with different keys and values.

The valueGetter Property node is slightly different however. It has a value property which is an ArrowFunctionExpression node. This has a params property, which in this case is an empty array, and a body property, which in this case is simply a Literal node with a value “bar”. A more complicated function might have a BlockStatement node with its own subtree.

Different Codes, Different Nodes

Hopefully the above description gives you a flavour of the types of nodes which are available and how to navigate the AST, expanding child nodes until you reach a ‘leaf’ node which has no children (for example, a Literal node). The precise types of nodes which will be in your AST will depend on the code you write, but the principles are the same.

Step 3 - Find Your Entry Point

Once you’ve explored your AST, you’ll need to work out what node you need to start at. You could start from the top level Program node and navigate down the AST within your rule, but you’ll just be introducing unnecessary complexity. The entry point also constrains the code for which your rule is run, so if you jump in at the top level, all of your code will be analysed, which is computationally expensive.

In my example, we know that we’re looking for column definitions, which will come as an array of objects. Therefore, it makes sense for my entry point to be an ArrayExpression node. Of course, most arrays in the codebase probably aren’t column definitions, but there’s no way of knowing which ones are, so we’ll have to check all of them. Once we’re inside the rule, we can use other data to break out early from arrays which clearly aren’t column definitions (for example, if they contain Literal nodes instead of ObjectDeclaration nodes), but you have to start somewhere!

Step 4 - Write Your Rule

I will assume that following this tutorial or another, you have the framework to create a rule, so this section will focus on writing the rule itself.

The standard beginning to a rule looks like this:

{
  create: context => {
    return {
      ArrayExpression(node) {
        ...
      }
    }
  }
}

The only thing I use the context for in my rule is to report the outcome back to the user, but it can be used for other things. You then return an object with a function named for your entry point node - in my case an ArrayExpression. Each time an ArrayExpression node is encountered in the AST, this function will be called with that node as an argument.

This node will have all the properties we observed in the section on the AST. So, using those properties we can write the rule.

The first step is to pull the elements out of the node, and filter them for ObjectExpressions, as we know that is what an array of column definitions looks like.

const {elements} = node;
elements = elements.filter(element => element.type === 'ObjectExpression');

Once we have the ObjectDeclaration nodes, we want to iterate through them, and determine if they have a colId Property node. If they do, we want to store the id so that we can later determine if it is unique.

The other requirement is that if there was no colId property, the field would be used as the column ID, so we also need to store the field property in this case.

Therefore, continuing the code for our rule:

const ids = {}
elements.forEach(({properties}) => {
  const colIdDeclaration = properties.find(p => {
    if (!p.key && !p.value) return false;
    return (
      p.key.type ==='Identifier' && p.key.name === 'colId' && p.value.type === 'Literal'
    );
  });
  if (colIdDeclaration) {
    // If we have already recorded this id
    if (ids[colIdDeclaration.value.value]) {
      // Add it to the array of values against that id
      ids[colIdDeclaration.value.value] = [...ids[colIdDeclaration.value.value], colIdDeclaration.value.value]
    } else {
      ids[colIdDeclaration.value.value] = [colIdDeclaration.value.value]
    }
  } else {
    const colIdDeclaration = properties.find(p => {
      if (!p.key && !p.value) return false;
      return (
        p.key.type ==='Identifier' && p.key.name === 'colId' && p.value.type === 'Literal'
      );
    });
    if (colIdDeclaration) {
      // If we have already recorded this id
      if (ids[colIdDeclaration.value.value]) {
        // Add it to the array of values against that id
        ids[colIdDeclaration.value.value] = [...ids[colIdDeclaration.value.value], colIdDeclaration.value.value]
      } else {
        ids[colIdDeclaration.value.value] = [colIdDeclaration.value.value]
      }
    }
  }
})

We now have a map of ids to an array of those ids. In the valid case, this will be a one to one mapping, and in an invalid case you will have multiple values in one or more of the arrays. In the case where we’re analysing an array which isn’t column definitions at all, the ids map will be empty. Therefore, if we filter the values of the map for arrays which are longer than 1, success or failure is represented by the resultant array being empty or not.

In the valid case, we just want to exit the rule by returning. In the invalid case we use the context to report the error to the developer, with the node responsible, and enough information to diagnose the problem. In this case, that means returning the offending array node, stating that column definitions need unique ids, and providing the offending ids.

const nonUnique = Obejct.values(ids).filter(arr => arr.length > 1);;

if (nonUnique.length === 0) return;
const data = Object.keys(ids)
  .filter(key => nonUnique.includes(ids[key]))
  .join(', ')

  context.report({
    node,
    message: 'Ag grid column definitions require a unique field of colId property. Responsible ids: ',
    // nonUnique in the string will be populated by the matching key in data
    data: {nonUnique: data}
  })

Step 5 - Think About Edge Cases

It can be quite easy to provide a false positive, or a false negative from your linting rule if you haven’t considered all the code that might be going through it.

For example, with this rule we just wrote, we will throw an error for any array containing objects without a colId or field property. We don’t actually do a check to see if this is an array of column definitions. This is actually quite a tricky problem to solve, as there aren’t really any uniquely identifying features of a column definition, other than its context.

In the end, on my project we didn’t solve this by adding logic to the rule. Instead, we made use of the convention that all our files containing column definitions are of the format *ColumnDefinitions.js, and that the only array they contain are the column definitions. So we only run the rule on these files.

As a further example, I wrote another rule for AG Grids which ensured that each column definition had a column ID (this rule ensures that each column ID is unique, but not that every definition has one). My inital pass iterated through the ObjectDeclaration nodes the same as this one, and checked for colId or field properties, and failed if they weren’t found for any node.

This worked for a simple list of column definitions, such as I’ve shown in this post. However, there is a case where column definitions can be grouped together using the children property. This array of column definitions would incorrectly fail my rule, because the ObjectDeclaration which has children doesn’t have a column ID. So I had to write an extra check to see if a node has a children property, and if it does, then don’t fail the rule if it lacks a column ID.

I actually only noticed this false negative once the rule was running in the codebase and I opened a file that used that pattern. This is the nature of writing these rules - they may need tweaking as your code evolves, and that’s ok. There’s no need to get them perfect first time, but it is worth spending a little time considering whether there are any edge cases you need to account for up front.

Your Turn!

I hope this has been a helpful guide on how to go about writing a custom ESLint rule for JavaScript. And now, the best way to learn is to practise! So have at it - write some rules to enforce best practice in your project, or try it out in a playground or wherever!