How I built my first custom ESLint rule
November 19, 2019 / 10 min read
Last Updated: November 19, 2019When I work with React or more generally with Javascript, I always use ESLint for linting. Although I've been very familiar with how to use and configure this tool, I've never actually written a custom ESLint rule from scratch until recently. At first, it sounded like a daunting task, but it ended up teaching me quite a few things. This is what this article is about: how I built this specific rule and how I learned about "Abstract Syntax Tree". Let's dive in together!
A simple rule
The rule I had to implement stated the following: when using the validate method from the yup package, we want yup.validateSync()
to be preceeded by CHECK &&
; hence the following snippets will show an error
1yup.validateSync();
1yup.validateSync() && CHECK;
and the next code snippets are valid:
1CHECK && yup.validateSync();
1CHECK && yup.validateSync() && SOMETHINGELSE;
Setting up our ESLint plugin
To create our custom ESLint rule, we'll need to build a ESLint plugin. Creating a ESLint plugin is similar to creating any other NPM project, except that the name of the package needs to start with eslint-plugin-
.
Let's create our new project from scratch and install ESLint as a dev dependency:
Commands to initialize our ESLint plugin
1mkdir eslint-plugin-custom23cd eslint-plugin-custom45yarn init67yarn install -D eslint
When it comes to organizing the different files and folder of the project, ESLint has a standard way of doing so. For this post, we can follow what is adivised in the official documentation about working with rules, so we'll create a file called check-before-type-validation.js
where we will implement our rule.
How to implement our rule
A ESLint rule contains 2 main parts:
meta
: an object where we will specify the usage of our rule.create
: a function that will return an object with all the methods that ESLint will use to parse our statement. Each method returned is an AST node.
What is an AST (Abstract Syntax Tree)
You might have seen or heard about ASTs in the past but here's a definition just in case:
an AST is simplified and condensed tree representation of the structure of source code written in a given programming language. It is "abstract" as it does not represent every detail appearing in the real syntax but just the content or structural details.
To build the ESLint rule, we need to get the represention of the expression CHECK && yup.validateSync();
in a AST and let the create
function return an error everytime the tree for the given expression does not match the valid tree. To find the AST representation of our expression you can use AST Explorer, which was very helpful for me.
However, before doing all that, let's start by addressing the meta
section of our rule.
Meta
Let's start by adding the basic structure of our rule and the meta to check-before-type-validation.js
Basic structure of our ESLint rule
1module.exports = {2'type-check-before-yup': {3meta: {4docs: {5description: '"yup.validateSync()" needs to be preceded by “CHECK &&”',6},7schema: [], // no options8messages: {9unexpected:10'"yup.validateSync()" is found but is not preceded "CHECK &&"',11},12},13create: function (context) {14return {15// AST goes here16// see next part17};18},19},20};
We can see above that we've added 2 important fields: messages and docs. The string under messages.unexpected
is the message that will be displayed when the rule will fail. The one under docs.description
provides a short description of the rule which can be display by some text editors like VSCode.
Create
For this part, let's first go to AST explorer and write our statement to see how it translates into AST. By entering CHECK && yup.validateSync()
we should get the following output:
AST representation of our expression
1{2"type": "Program",3"start": 0,4"end": 27,5"body": [6{7"type": "ExpressionStatement",8"start": 0,9"end": 27,10"expression": {11"type": "LogicalExpression",12"start": 0,13"end": 27,14"left": {15"type": "Identifier",16"start": 0,17"end": 5,18"name": "CHECK"19},20"operator": "&&",21"right": {22"type": "CallExpression",23"start": 9,24"end": 27,25"callee": {26"type": "MemberExpression",27"start": 9,28"end": 25,29"object": {30"type": "Identifier",31"start": 9,32"end": 12,33"name": "yup"34},35"property": {36"type": "Identifier",37"start": 13,38"end": 25,39"name": "validateSync"40},41"computed": false42},43"arguments": []44}45}46}47],48"sourceType": "module"49}
To write our rule, we can start by highlighting yup.validateSync()
. We see from the AST tree that this expression is a CallExpression
:
We'll first need ESLint to find that specific node with the object name yup
and a property name validateSync
in a CallExpression
. If found, we can check one of the parents of that node to see if CHECK &&
is present. Hence, we can start by writing the following code:
Writing the rule (step 1)
1create: function(context) {2return {3// Rule methods - AST Node Type4CallExpression: function(node) {5const callee = node.callee;6// this will return the properties of the current CallExpression:7if (8callee.object &&9callee.object.name === 'yup' &&10callee.property &&11callee.property.name === 'validateSync'12) {13// check one of the parents to see if "CHECK &&" is present14}15}16}17}
The next part of the AST tree that we're looking for is a LogicalExpression
. We can see from the screenshot above that it's present 2 levels up the tree. We can deduct from this that if this parent were not to be a LogicalExpression
, our ESLint rule should report an error. We can then continue writing our code snippet above by adding the following:
Writing the rule (step 2)
1if (2callee.object &&3callee.object.name === 'yup' &&4callee.property &&5callee.property.name === 'validateSync'6) {7// check one of the parents to see if "CHECK &&" is present89const calleeLogicalExpression = callee.parent.parent;1011if (calleeLogicalExpression.type !== 'LogicalExpression') {12// if that "grand parent" expression is not of type 'LogicalExpression' (meaning there's no logical operator || or &&)13// or that the left part of that expression is not CHECK (the right part being yup.validateSync)14// then we report this case as a lint error15context.report({ node, messageId: 'unexpected' });16}17}
As you can see above, in order to have ESLint reporting the error, we need to call the context.report
function. We pass the messageId that we specified in the meta of our rule instead of typing the full message as it is advised in the ESLint documentation.
Next, we have to check that if it is a LogicalExpression
the operator of that expression is actually a "AND" and not a "OR":
Writing the rule (step 3)
1if (2callee.object &&3callee.object.name === 'yup' &&4callee.property &&5callee.property.name === 'validateSync'6) {7// check one of the parents to see if "CHECK &&" is present89const calleeLogicalExpression = callee.parent.parent;1011if (calleeLogicalExpression.type !== 'LogicalExpression') {12// if that "grand parent" expression is not of type 'LogicalExpression' (meaning there's no logical operator || or &&)13// or that the left part of that expression is not CHECK (the right part being yup.validateSync)14// then we report this case as a lint error15context.report({ node, messageId: 'unexpected' });16} else {17// if all the above case are satisfied but the operator of the logical expression is not '&&'18// then we report this case as a lint error19if (calleeLogicalExpression.operator !== '&&') {20context.report({ node, messageId: 'unexpected' });21}22}23}
With this code our ESLint rule will report an error for the following:
1yup.validateSync(); // LogicalExpression missing2CHECK || yup.validateSync(); // The LogicalExpression has not the expected operator
However if we have something like the following:
1TEST && yup.validateSync();
our rule will not catch any error. So let's go back to our AST tree to see what we can do here.
We can see that a LogicalExpression
has 3 main parts:
- the left part:
CHECK
- the operator:
&&
or||
- the right right:
yup.validateSync()
so for the last part of our rule we want to check whether the name of the left part of our LogicalExpression
is CHECK
:
Writing the rule (step 4)
1if (2callee.object &&3callee.object.name === 'yup' &&4callee.property &&5callee.property.name === 'validateSync'6) {7// check one of the parents to see if "CHECK &&" is present89const calleeLogicalExpression = callee.parent.parent;1011if (calleeLogicalExpression.type !== 'LogicalExpression') {12// if that "grand parent" expression is not of type 'LogicalExpression' (meaning there's no logical operator || or &&)13// or that the left part of that expression is not CHECK (the right part being yup.validateSync)14// then we report this case as a lint error15context.report({ node, messageId: 'unexpected' });16} else if (calleeLogicalExpression.left.name !== 'TYPE_CHECK') {17context.report({ node, messageId: 'unexpected' });18} else {19// if all the above case are satisfied but the operator of the logical expression is not '&&'20// then we report this case as a lint error21if (calleeLogicalExpression.operator !== '&&') {22context.report({ node, messageId: 'unexpected' });23}24}25}
How to test our rule
Now that we wrote all the cases we want our rule to handle, it's time to test it. We're lucky, because ESLint comes with its own tool for testing rules called RuleTester
. With this tool, we can specify all the cases we want to run the rule against and whether these cases are expected to pass or be reported as errors. Our test will live in tests/lib
and will import the rule we just wrote in the previous part:
Test for our ESLint rule
1// we import the check-before-type-validation ESLint rule2const rules = require('../../lib/check-before-type-validation');3const RuleTester = require('eslint').RuleTester;45const ruleTester = new RuleTester();67// Here we pass the 'unexpected' messageId since it is the error we expect to be reported by the rule8const errors = [{ messageId: 'unexpected' }];910const typeCheckRule = rules['type-check-before-yup'];1112// Our test run with all the different test cases13ruleTester.run('type-check', typeCheckRule, {14valid: [15{16code: 'CHECK && yup.validateSync()',17errors,18},19{20code: 'yup.someOtherCommand()',21errors,22},23],24invalid: [25{26code: 'yup.validateSync()',27errors,28},29{30code: 'OTHER && yup.validateSync()',31errors,32},33{34code: 'CHECK || yup.validateSync()',35errors,36},37],38});
In the previous code snippet we can see that we're going to test our rule in 5 different cases:
- an error is not reported if we have the statements
CHECK && yup.validate
oryup.someOtherCommand()
- an error is reported if we have the following statements:
yup.validateSync()
(missingLogicalExpression
) orOTHER && yup.validateSync
(wrong left part of theLogicalExpression
) orCHECK || yup.validateSync()
(wrong operator).
We can then run this test with Jest or any other test runner and we should get an output similar as this:
1type-check23valid45✓ OTHER && CHECK && yup.validateSync() (45ms)67✓ CHECK && yup.validateSync() (3ms)89✓ yup.someOtherCommand() (1ms)1011invalid1213✓ yup.validateSync() (3ms)1415✓ OTHER && yup.validateSync() (1ms)1617✓ CHECK || yup.validateSync() (2ms)
Now that we've ensured that the rule is working as expected, we can publish it as an NPM package and add it as a plugin to any ESLint configuration we want.
This whole process might seem like a lot at first, especially since it involves dealing with AST which isn't the most accessible thing to learn. But, now that we know what the anatomy of an ESLint rule is, we can appreciate even more the insane amount of work done by the community to provide us with all this linting rules that we're using on a day to day basis to make our codebase cleaner and more consistent.
Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.
Have a wonderful day.
– Maxime
A guide to get started with AST (Abstract Syntax Tree) and custom built ESLint plugins