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!