Writing a custom ESLint rule
Within the Web team at Depop we make heavy use of ESLint, a tool that analyses code and flags problems at build time based on a chosen set of rules. This helps to keep code consistent, maintainable and prevent common bugs. For the most part, the vast selection of built-in defaults and community rules serve us well.
But what happens when you want to enforce a rule that does not currently exist in the ESLint community? This was the situation we found ourselves in and is the focus of this article.
The problem
We follow the “backend for frontend” pattern (BFF) and have multiple Web APIs that sit in front of a large collection of BE services that cover various domains. These Web APIs act as a presentation layer for the website in order to provide data in the most useful way for our UIs to consume.
Within our Web APIs, we use Express routing and assign a TypeScript class to each route method. For example, we have a GET
method within our product web api that uses a ProductController
class. This contains a public method that retrieves a single product from the BE.
In order to monitor any problems with the logic in our class functions, we have an ObservationService
which allows us to watch the function and if it throws an error, we log that error to our logging platform. Having our core methods watched is vital for us to have visibility of problems in production, and yet it is quite easy for an engineer to forget to add the ObservationService’s watch
command in the constructor:
After discussing this problem as a group, we decided that the best way forward was to create a custom ESLint rule which would complain if any public class method was not being watched by the ObservationService
. This was not something we had done before and so the journey began. The following sections outline the steps that were taken to achieve this…
Creating an ESLint Plugin
In order to create an ESLint rule, we needed an ESLint plugin to house the rule. The official docs suggest using the Yeoman generator to create the plugin but the generator does not support TypeScript which we’re rather fond of (and is useful when creating rules). Instead, we went down the manual route.
These are the main steps required to generate a TypeScript based plugin:
- Create a new package e.g. using
npm init
- The package name needs to be in the formateslint-plugin-<plugin-name>
or if you want a scoped package@<scope>/eslint-plugin-<plugin-name>
- Setversion
to a suitable starting value like1.0.0
- Setmain
todist/index.js
- Create the following empty directories
docs/rules
,lib/rules
andtests/lib/rules
. We’ll add to these later. - Add a
tsconfig.json
file to set up the repo as a TypeScript repo. For more info on the various options click here. Some key compiler options we used are"target": "ES5"
,"module": "CommonJS"
and"lib": ["ESNext"]
. Add thelib
folder to the include path i.e."include:”: [“lib’]
as that will be the source of the build. Make sure that you have the output directory set to the same folder as the one defined inmain
in package.json (i.e."outDir": "dist"
). You will also need a tool to compile the TS code to JS, we usedtsc
which can be accessed by adding TypeScript as a dependency to the repo. - You will probably want to add some tools like ESLint and Prettier to ensure your source code is nice and consistent. (Yes, even your custom ESLint rule needs linting!). The setup of these tools is outside the scope of this article.
At this point we had an empty ESLint plugin and could start writing our custom rule…
Creating the skeletal structure of a custom ESLint rule
There is a very handy utility called @typescript-eslint/utils which acts as a replacement package for ESLint. It exports all of the same objects and types, but with typescript-eslint
support (see here for why we need this). Add @typescript-eslint/utils as a dependency to start using it.
Before creating the rule, it’s important to think about exactly how you want the rule to be named. The name of the rule needs to make sense to both the maintainers of the rule and its consumers. To recap, we were looking to create a rule that warns if any public class methods were not being watched by the ObservationService
. We went for the snappy class-methods-observation-service-watch
.
The first file to create is for the rule itself and it makes sense to name that file the same as the rule e.g. class-methods-observation-service-watch.ts
. This needs to be added to the lib/rules
folder. Add the following template in there to get started:
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
const createRule = ESLintUtils.RuleCreator(
(name) =>
`https://github.com/myrepo/blob/master/docs/rules/${name}.md`
);
export const rule = createRule({
create(context) {
return {};
},
name: '<name of eslint rule>',
meta: {
type: 'problem', // `problem`, `suggestion`, or `layout`
docs: {
description:
"",
recommended: 'error',
},
messages: {},
schema: [], // Add a schema if the rule has options
},
defaultOptions: [],
});
The first thing to do is replace <name of eslint rule>
with the actual name of the rule e.g. class-methods-observation-service-watch
.
In the meta
object change the following:
- For the
type
choose one fromproblem
,suggestion
orlayout
. The definitions can be found here, but in our case we were creating one of typeproblem
. - Within the
docs
section:
- Add a brief description of what the rule does e.g.Ensures that public class methods are being watched by the ObservationService’s watch method
- Add a recommendation level for the rule. Choose one fromerror
,strict
,warn
orfalse
. This is used by build tools to generate configs based on presets. We went forerror
. - The
messages
object is where you define the messages that the consumer of the rule will see. The key is an identifier that will be referenced when writing the rule and the value is the error message itself. In our case, we only had 1 type of problem that we were interested in, so our object looked like:
The rule logic
Writing custom ESLint rules involves heavy use of Abstract Syntax Trees (ASTs). If you’re unfamiliar with ASTs, you should definitely read this article for a brief intro before continuing because this is outside the scope of this article.
To help with writing your rule, you’re going to need some assistance finding the right nodes corresponding to the lines of code you’re linting. This is where tools like AST Explorer are your friend. In the left panel of the explorer you can paste in some example code that would represent the problem your rule is trying to lint. Then by clicking on different parts of the code, the right hand side will update with the corresponding nodes.
The main rule logic needs to go in the return block of the create
method within the createRule
helper:
export const rule = createRule({
create(context) {
return {
// Rule logic
};
}
}
For our rule, the first step was to identify if the analysed file contained a class. By visiting AST Explorer we could drop in some example code of what a typical class-based file would look like:
interface ProductControllerArgs {
observationService: IObservationService;
}
export class ProductController {
constructor(args: ProductControllerArgs) {
this.fetchSingleProduct = args.observationService.watch(
this.fetchSingleProduct,
{
classFunctionName: 'ProductController.fetchSingleProduct',
layerName: Layer.Controller,
}
);
}
public fetchSingleProduct = () => {
// Implementation code
}
}
Then by clicking on the class
keyword in the left panel of the explorer, the right panel showed something like this:
The ClassDeclaration
highlighted in blue is the node that represents a class so our create method becomes the following:
export const rule = createRule({
create(context) {
return {
ClassDeclaration: (node) => {
}
};
}
}
We use ClassDeclaration
as the key in the return block and the value is a function that takes the node as an argument. In theory the function could be called multiple times (as there could be multiple classes in the same file) but in our world there should only ever be one class per file. This node argument represents the class in our code.
Let’s take a minute to think about what our rule is supposed to do. Essentially we want to show a warning if any public method within a class is not being watched by the ObservationService
. The approach we took is to first find all the public methods within the class and then check if any of them are not being watched by the ObservationService
.
If we return to the AST Explorer and click on the fetchSingleProduct
method (which is an example of such a public method we want to watch) we can see the following is highlighted on the right panel:
With this information, we can add the following code within the ClassDeclaration
method to find all the public methods:
ClassDeclaration: (node) => {
const publicMethods = node.body.body.filter(
(item): item is TSESTree.PropertyDefinition =>
item.type === TSESTree.AST_NODE_TYPES.PropertyDefinition &&
item.accessibility === 'public' &&
item.value?.type === TSESTree.AST_NODE_TYPES.ArrowFunctionExpression
);
}
We are checking for items in the body.body
array which have a type of PropertyDefinition
, accessibility value of public
and value.type
is an arrow function. You will also notice that the callback function passed to node.body.body.filter()
is a type guard. This is to take advantage of the @typescript-eslint
types. We sometimes have to narrow our types so that we get useful type suggestions and that’s what we are doing here so that the publicMethods
array has the type TSESTree.PropertyDefinition[]
and not something broader. With this, we now have an array of all the public methods in the class.
Now we want to loop through all these public methods and check if any of them are not being watched by the ObservationService
's watch method. We know that the observationService.watch
method is initialised in the class constructor so we should start there. Clicking on the constructor in the AST Explorer shows this in the right panel:
The first potential linting error is if a constructor does not exist within the class. We can check for the constructor by adding the following code:
publicMethods.forEach((publicMethod) => {
if (publicMethod.key.type === TSESTree.AST_NODE_TYPES.Identifier) {
let expectedMethodName = publicMethod.key.name;
const constructorMatch = node.body.body.find(
(item): item is TSESTree.MethodDefinition =>
item.type === TSESTree.AST_NODE_TYPES.MethodDefinition &&
item.kind === 'constructor'
);
if (!constructorMatch) {
return context.report({
messageId: 'publicClassMethodsMustBeWatched',
node: publicMethod,
});
}
}
}
The constructor exists within the node.body.body
node. It should have a MethodDefinition
type and have a “kind” value set to constructor
. If there is no match for the constructor then we are able to trigger our first linting error.
You will remember that we previously defined a message with an identifier of publicClassMethodsMustBeWatched
within the meta
section of our rule. To trigger a linting error we need to call context.report
, passing it an object with the messageId
we want to use and the node that triggered it. In this case, the messageId
is publicClassMethodsMustBeWatched
and the node is the current node in the forEach
loop that triggered the warning:
if (!constructorMatch) {
return context.report({
messageId: 'publicClassMethodsMustBeWatched',
node: publicMethod,
});
}
Hurrah! Our rule is finally doing something useful.
The final bit of linting that we need is for when there is a constructor but a public method is not being watched by the ObservationService
. We should by now get the idea of how to identify the correct part of the code in the AST Explorer. Remember that a valid observationService.watch
call in a class constructor would look something like this:
this.fetchSingleProduct = args.observationService.watch(
this.fetchSingleProduct,
{
classFunctionName: 'ProductController.fetchSingleProduct',
layerName: Layer.Controller,
}
);
The following code can be added below the previous context.report
call to achieve the check:
const watchMatch = constructorMatch.value.body?.body.find(
(statement) => {
if (
statement.type ===
TSESTree.AST_NODE_TYPES.ExpressionStatement &&
statement.expression.type ===
TSESTree.AST_NODE_TYPES.AssignmentExpression &&
statement.expression.right.type ===
TSESTree.AST_NODE_TYPES.CallExpression &&
statement.expression.right.callee.type ===
TSESTree.AST_NODE_TYPES.MemberExpression &&
((statement.expression.right.callee.object.type ===
TSESTree.AST_NODE_TYPES.MemberExpression &&
statement.expression.right.callee.object.property.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.callee.object.property.name ===
'observationService') ||
(statement.expression.right.callee.object.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.callee.object.name ===
'observationService')) &&
statement.expression.right.callee.property.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.callee.property.name === 'watch' &&
statement.expression.right.arguments[0].type ===
TSESTree.AST_NODE_TYPES.MemberExpression &&
statement.expression.right.arguments[0].object.type ===
TSESTree.AST_NODE_TYPES.ThisExpression &&
statement.expression.right.arguments[0].property.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.arguments[0].property.name ===
expectedMethodName
) {
return true;
}
return false;
}
);
Now, I will admit that this is not very pretty at first glance. A lot of the verboseness is to make sure we’re narrowing the types accordingly. So let’s try and break this down…
We are initially looping through the constructor’s value.body.body
array to find all the methods that live in the constructor.
Then to find the observationService.watch
call we do some checks for statement.expression.right.callee.object.property.name === 'observationService'
along with statement.expression.right.callee.property.name === 'watch'
.
Next we have a check for statement.expression.right.arguments[0].object.type === TSESTree.AST_NODE_TYPES.ThisExpression
along with statement.expression.right.arguments[0].property.name === expectedMethodName
to check that that the first argument passed to observationService.watch
is the expected method name e.g. this.fetchSingleProduct
If there is a match for all these conditions within the find method then we return true
, otherwise we return false.
The result is assigned to the watchMatch
constant. If there is no match then we trigger the same linting error as before:
if (!watchMatch) {
return context.report({
messageId: 'publicClassMethodsMustBeWatched',
node: publicMethod,
});
}
The logic is now complete and our lint rule can detect if any public methods are not being watched. The final version of our rule is as follows:
export const rule = createRule({
create(context) {
return {
ClassDeclaration: (node) => {
const publicMethods = node.body.body.filter(
(item): item is TSESTree.PropertyDefinition =>
item.type === TSESTree.AST_NODE_TYPES.PropertyDefinition &&
item.accessibility === 'public' &&
item.value?.type === TSESTree.AST_NODE_TYPES.ArrowFunctionExpression
);
publicMethods.forEach((publicMethod) => {
if (publicMethod.key.type === TSESTree.AST_NODE_TYPES.Identifier) {
let expectedMethodName = publicMethod.key.name;
const constructorMatch = node.body.body.find(
(item): item is TSESTree.MethodDefinition =>
item.type === TSESTree.AST_NODE_TYPES.MethodDefinition &&
item.kind === 'constructor'
);
if (!constructorMatch) {
return context.report({
messageId: 'publicClassMethodsMustBeWatched',
node: publicMethod,
});
}
const watchMatch = constructorMatch.value.body?.body.find(
(statement) => {
if (
statement.type ===
TSESTree.AST_NODE_TYPES.ExpressionStatement &&
statement.expression.type ===
TSESTree.AST_NODE_TYPES.AssignmentExpression &&
statement.expression.right.type ===
TSESTree.AST_NODE_TYPES.CallExpression &&
statement.expression.right.callee.type ===
TSESTree.AST_NODE_TYPES.MemberExpression &&
((statement.expression.right.callee.object.type ===
TSESTree.AST_NODE_TYPES.MemberExpression &&
statement.expression.right.callee.object.property.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.callee.object.property.name ===
'observationService') ||
(statement.expression.right.callee.object.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.callee.object.name ===
'observationService')) &&
statement.expression.right.callee.property.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.callee.property.name === 'watch' &&
statement.expression.right.arguments[0].type ===
TSESTree.AST_NODE_TYPES.MemberExpression &&
statement.expression.right.arguments[0].object.type ===
TSESTree.AST_NODE_TYPES.ThisExpression &&
statement.expression.right.arguments[0].property.type ===
TSESTree.AST_NODE_TYPES.Identifier &&
statement.expression.right.arguments[0].property.name ===
expectedMethodName
) {
return true;
}
return false;
}
);
if (!watchMatch) {
return context.report({
messageId: 'publicClassMethodsMustBeWatched',
node: publicMethod,
});
}
}
});
},
};
},
name: 'class-methods-observation-service-watch',
meta: {
type: 'problem', // `problem`, `suggestion`, or `layout`
docs: {
description:
"Ensures that public class methods are being watched by the ObservationService's watch method",
recommended: 'error',
},
messages: {
publicClassMethodsMustBeWatched:
'Any public class method must be watched by the observationService',
},
schema: [], // Add a schema if the rule has options
},
defaultOptions: [],
});
It’s worth mentioning that this rule is not bulletproof, there may be variations of code that trigger false positives when using it but it served us well as a good v1!
Exporting the rule
After a rule has been created, it needs to be exported. In the root of the lib
folder we created an index.ts
file with the following:
import { rule as classMethodsObservationServiceWatch } from './rules/class-methods-observation-service-watch';
const config = {
rules: {
'class-methods-observation-service-watch':
classMethodsObservationServiceWatch,
},
};
export = config;
The key in the rules
object must match the name of the rule defined in the createRule
helper which in our case is class-methods-observation-service-watch
. The value is then set to the imported rule.
The strange export = config
syntax is a way to support the CommonJS module system in the compiled rule. More info here.
Writing tests
ESLint rules should always have tests written for them. As well as safe-guarding against future regressions, the somewhat isolated nature of writing ESLint rules adds extra value to writing tests as they give immediate feedback on whether your rule is linting as expected.
To begin, add a file in the tests/lib/rules
folder with the filename named after your new rule (you can name it however you like but this would be a logical naming convention). In our case we named it class-methods-observation-service-watch.test.ts
.
Within the new test file add the following template:
import { ESLintUtils } from '@typescript-eslint/utils';
import { rule } from '../../../lib/rules/<path to eslint rule>';
const ruleTester = new ESLintUtils.RuleTester({
parser: '@typescript-eslint/parser',
});
ruleTester.run('<name of eslint rule>', rule, {
valid: [
{
code: ``,
}
],
invalid: [
{
code: ``,
errors: [{ messageId: '' }],
},
],
});
Update <path to eslint rule>
in the import statement to point to your new rule and also update <name of eslint rule>
to match the name of your new rule. For our rule, the value class-methods-observation-service-watch
was used for both.
The main test logic is handled inside the ruleTester.run()
helper. You need to supply it examples of valid code that won’t trigger any linting errors and also invalid code examples that do trigger errors. For each invalid code example that you provide, you also need to specify the messageId
of the linting error that you're expecting to be triggered e.g. publicClassMethodsMustBeWatched
. This is the messageId that was defined previously.
The code examples that you supply to the helper can be large blocks of code (e.g. entire classes) so you will want to place them inside template string literals to make them somewhat readable. Try to reduce the code examples to the minimum amount of code that proves the linting rule is correct. Also try to think of all the different ways the code can be written (e.g. The rule might need to work with both arrow functions AND function declarations) and provide examples of them all.
An example test file for our rule is as follows:
import { ESLintUtils } from '@typescript-eslint/utils';
import { rule } from '../../../lib/rules/class-methods-observation-service-watch';
const ruleTester = new ESLintUtils.RuleTester({
parser: '@typescript-eslint/parser',
});
ruleTester.run('class-methods-observation-service-watch', rule, {
valid: [
{
code: `
interface IMyControllerArgs {
observationService: IObservationService;
}
export class MyController {
constructor(args: IMyControllerArgs) {
this.doSomething = args.observationService.watch(this.doSomething, {
classFunctionName: 'MyController.doSomething',
layerName: Layer.Controller
});
}
public doSomething = async () => {
// implementation
};
}
`,
}
],
invalid: [
{
code: `
interface IMyControllerArgs {
observationService: IObservationService;
}
export class MyController {
constructor(args: IMyControllerArgs) {}
public doSomething = async () => {
// implementation
};
}
`,
errors: [{ messageId: 'publicClassMethodsMustBeWatched' }],
}
],
});
Once your tests are complete then you just need to run them with a suitable testing framework like Jest.
Documentation
Every linting rule should have a document that outlines how the rule should be used. One example of where this document is linked to is in code editors when the linting error is triggered e.g. when hovering over the linting error, a tooltip appears and a hyperlink launches the rule doc for the triggered rule in the user's browser:
To begin, add a file in the docs/rules
folder, with the filename named after the name of your new rule but with a .md
extension. In our case we named it class-methods-observation-service-watch.md
. The main thing to watch out for is that the location of your doc must match the path that was defined when we first created our rule:
const createRule = ESLintUtils.RuleCreator(
(name) =>
`https://github.com/myrepo/blob/master/docs/rules/${name}.md`
);
In the new markdown file add the following template:
# Ensures that...
<Description>
## Rule Details
This rule aims to...
Examples of **incorrect** code for this rule:
Examples of **correct** code for this rule:
## When Not To Use It
Now you just need to fill in the blanks! Give a general description of the rule at the top then go into more detail, giving examples of correct and incorrect code. Finally, you can add details on when not to use the rule.
You can obviously structure your rule doc content as you like but this is a good base for the things you’ll want to include. The key thing to have in the back of your mind is to consider what somebody completely new to your rule would want to know when using it.
Local Development
You’ve now completed the main steps required to create a custom ESLint rule! Add a build step to compile the plugin’s source files into a final version inside the dist
folder.
You’re going to want to fully test your new rule before publishing it, so you’ll want a separate repo where you can add this new plugin as a dependency. There are a number of tools available to symlink your consuming repo to the plugin repo e.g npm link and yalc (we use yalc), so go for whatever works best for you.
The package name to add to the package.json
of your consuming repo is in the format eslint-plugin-<plugin-name>
(or @<scope>/eslint-plugin-<plugin-name>
for scoped packages).
Once linked, add your plugin name to the plugins
array in the eslintrc
file of your consuming repo:
{
"plugins": [
"<plugin-name>"
]
}
Note: If you created a scoped package for your plugin then you would add the scope as a prefix to the plugin name e.g.@<scope>/<plugin-name>
Then add the rule(s) you want to enable in the rules
section of the same eslintrc
file:
{
"rules": {
"<plugin-name>/<rule-name>": "error"
}
}
Note: If you created a scoped package for your plugin then you would add the scope as a prefix to the plugin name e.g.@<scope>/<plugin-name>/<rule-name>: "error"
For the rule that we created in this article, <rule-name>
would equate to class-methods-observation-service-watch
.
Publishing to NPM
With the new plugin in place, all that’s left to do is publish it to a registry which is most likely to be npm. We used a very nice tool called semantic-release which takes care of the whole release process for you. Once published, it will be available for the world to consume! (Or just to your org when using private scoped packages).
Final thoughts
Whilst not the simplest thing you might ever do, creating a custom ESLint rule offers a lot of value by catching problems at the build stage for any custom patterns that you may want to enforce in your code. Getting to grips with abstract syntax trees is the key to your progress but once you get used to them then it’s fairly straight forward.
There are a number of ways the rule in this article could still be improved. For example, catering for more edge cases in the input code style, adding more test cases and also implementing an auto-fix feature so that users can trigger the observationService.watch
code to be auto-generated when they run eslint
with the — fix
flag. These are all improvements that we will be looking to add to our rule to ensure we get the most out of it and also to keep learning new things!
Hopefully this guide gave you a helping hand in understanding the steps involved in getting things off the ground!
You can view the full code for the rule created in this article here: https://github.com/depoplabs/eslint-plugin-depop-demo