Writing a custom ESLint rule

Jonny Levy
Engineering at Depop
16 min readMay 23, 2023

--

The ESLint logo courtesy of https://eslint.org/branding/
The ESLint logo courtesy of https://eslint.org/

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:

A code sample showing how the observation service is used to watch our class methods
A code sample showing how the observation service is used to watch our class methods

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:

  1. Create a new package e.g. using npm init
    - The package name needs to be in the format eslint-plugin-<plugin-name> or if you want a scoped package @<scope>/eslint-plugin-<plugin-name>
    - Set version to a suitable starting value like 1.0.0
    - Set main to dist/index.js
  2. Create the following empty directories docs/rules , lib/rules and tests/lib/rules. We’ll add to these later.
  3. 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 the lib 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 in main in package.json (i.e. "outDir": "dist"). You will also need a tool to compile the TS code to JS, we used tsc which can be accessed by adding TypeScript as a dependency to the repo.
  4. 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 from problem , suggestion or layout . The definitions can be found here, but in our case we were creating one of type problem.
  • 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 from error, strict, warn or false. This is used by build tools to generate configs based on presets. We went for error.
  • 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 messages object where you define the messages that the consumer of the rule will see

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:

An example of what AST Explorer would show when clicking on the class keyword

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:

An example of what AST Explorer would show when clicking on the fetchSingleProduct method

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:

An example of what AST Explorer would show when clicking on the constructor

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:

An example of a tooltip in a user’s code editor that links to the rule doc
An example of a tooltip in a user’s code editor that links to the rule doc

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!

An animated gif showing our depop custom ESLint rule in action
An animated gif of the rule in action

You can view the full code for the rule created in this article here: https://github.com/depoplabs/eslint-plugin-depop-demo

--

--