Custom ESLint Rules For Faster Refactoring

Ryan Finni
Neighborhoods.com Engineering
8 min readMay 24, 2019

--

Photo by Tim Patch on Unsplash

When working as part of an engineering team, code consistency and standards play a huge role in the outcome and future maintainability of a product. They help increase readability and add to the overall enjoyment of working in a codebase. Code style, for example, should be enforced using tools that provide instant feedback and make it trivial to spot errors.

At Neighborhoods.com, we have an engineering value called The Pit of Success that states:

“Because others will share in my work, I must ensure that the work I produce guides my team toward good practices by making bad practices difficult.”

In following this value, we have adopted tooling that helps create a culture where it is difficult to stray from standards that have been set.

So how can we enforce good practices? While the code review process is a great way to identify problems, it’s possible that things will be missed. In addition, code reviews are not the time to be focusing on code style issues like missing commas, semicolons, or inconsistent spacing. A great automated solution to this is to use a linting tool like ESLint and one of the popular configs like eslint-config-airbnb. This way, an engineer gets live feedback of any problems and can fix them long before the code review process.

This setup works wonderfully in many cases, but it’s possible that you will have a need for rules outside of any existing ESLint configurations.

Our team has found a unique use case for custom ESLint rules: to track and enforce technical debt. For example, to find and remove node_module dependencies that have been overused, in an effort to decrease our overall JS bundle size. We do this on a gradual basis that doesn’t deviate too far from a normal workflow. As part of a task, if you make changes to a file that contains an error flagged by ESLint, you should refactor that file.

This has helped our team address tech debt at a steady rate, and without risky pull requests with hundreds of file changes.

This guide will show you how to write your own ESLint rules and how to use them to enforce refactoring over time.

Preface

This guide assumes you have at least some knowledge of ESLint and a project that already uses it. If you want to try this in a fresh project, create-react-app is an excellent tool to start one quickly.

Creating The Plugin

Start by making a directory in your project root named “eslint”. This directory will contain the rules we write as well as a few files needed to set up the plugin skeleton. Create a package.json file within this new directory. This file will contain your plugin name, a version number, and reference a file that will export all the rules pertaining to this plugin. You may copy/paste the code below.

{
“name”: “eslint-plugin-custom-rules”,
“version”: “1.0.0”,
“main”: “index.js”
}

Note: the name can be anything you want, but it must begin with “eslint-plugin” in order for it to work correctly.

Next we need a “rules” directory that will contain the rules we write. To start with, we’ll add a rule called “prevent-import”. Go ahead and make a file named “prevent-import.js” and add it to the rules directory we just created. We’ll come back to this empty file later.

Now we need to create an “index.js” file that will export all of our rules to make them available to ESLint. Create this file in the eslint directory we added previously, and copy/paste the following:

export default {
'prevent-import': require('./rules/prevent-import'),
};

We need to add our plugin as a dependency in our project package.json and then install it. Without doing this, ESLint won’t know where to look for our rules. Open up the “package.json” file in your project root and add an entry for your plugin, which points to the “eslint” directory that we created.

Next run npm install eslint-plugin-custom-rules@file:eslint. This will copy our ESLint plugin into the node_modules folder and allow it to be referenced in our main .eslint configuration.

Note: this plugin could also live somewhere else and not directly in your codebase. A good reason for that would be if you wanted to use the plugin across multiple projects. In that case, you would install it just as you would any other NPM dependency.

With our new plugin copied to our node_modules, we can finally tell ESLint that we want to use it. Open up the .eslintrc file and add your plugin and the empty rule that we created.

This allows our plugin to be used by the eslint command when run manually, as well as any ESLint support your editor may have built in. For VS Code users, you may need to restart your editor before it detects the new plugin.

With all of this setup finally out of the way, we now have an empty ESLint plugin. Now we’ll actually write some rules and see it in action.

Writing Custom Rules

Create a file or component in your codebase to use for this first example. I made a component named “DeprecatedButton.jsx”. Make sure that it exports something you can import into another file.

import React from 'react';const DeprecatedButton = () => (
<button type="button">A deprecated button</button>
);
export default DeprecatedButton;

Next import it into any other file and remember the import value as we’ll use it again shortly.

import logo from './logo.svg';
import './App.css';
import DeprecatedButton from './DeprecatedButton';

Lets go back to our empty “prevent-import.js” file. What we want to do is show a linting error if this component or function is imported. A common use case for this is to help prevent the use of a deprecated piece of code.

ESLint works by using AST (abstract syntax tree) to catch invalid patterns in your code. A handy tool to help inspect and write rules is AST explorer. https://astexplorer.net/. We’ll use it to help break down all the info that we can about our rule.

At the top of the AST explorer page, under Transform, choose ESLint V3 or V4.

Remove all the example code in the top left and bottom left panels and add the same import statement as above. You should see something like this. The top left is the example we want to test against, the bottom left is our ESLint rule and the top right is information about the contents of the top left.

Looking at the output in the top right, there is a lot of info. If you expand the body section, you’ll see that this line is of type “ImportDeclaration”. Since an import is the basis of our new rule, lets add that.

export default function(context) {
return {
ImportDeclaration(node) {

},
};
};

We want to check the value of “node”. A useful trick is to open your browser devtools console and log the output of the rule. You can easily see values like “context” and “node” this way.

What we’re looking for is the name of our import, DeprecatedButton. The “specifiers” array contains more data about each import. Lets map over specifiers to get the values from it. Also add a check to make sure we actually have specifiers before trying to map over them.

export default function(context) {
return {
ImportDeclaration(node) {
node.specifiers && node.specifiers.map((item) => {
console.log(item);
});
},
};
};

If you check the console now, you’ll see that we have access to an object named “local”. This object has specific info like the name of the import, and this is exactly what we’re looking for.

export default function(context) {
return {
ImportDeclaration(node) {
node.specifiers && node.specifiers.map((item) => {
console.log(item);
if (item.local.name === 'DeprecatedButton') {

}
});
},
};
};

Now we are ready to do something when we find an import named DeprecatedButton.

What next? The only thing not mentioned yet is what “context” is. Context is what allows us to act on our findings via a function called “report”. Report expects a node, the items location (line number), and an error message to display.

export default function(context) {
return {
ImportDeclaration(node) {
node.specifiers && node.specifiers.map((item) => {
if (item.local.name === 'DeprecatedButton') {
return context.report(node, item.loc, `Do not use ${item.local.name}`);
}
return null;
});
},
};
};

Now in the bottom right panel, you should see some error output.

// Do not use DeprecatedButton (at 1:9)
import {DeprecatedButton} from './DeprecatedButton';
// --------^

Make some changes to experiment with this further. For example, if you change the import to a named import instead of a default, the rule should still work. However, if you change the name of the import altogether, you should see “Lint rule not fired.”. This is exactly what we’d expect.

With our rule finished, add the contents of this new rule into the “prevent-import.js” file we created earlier.

// Complete rule
module.exports = {
create(context) {
return {
ImportDeclaration(node) {
node.specifiers && node.specifiers.forEach((item) => {
if (item.local.name === 'DeprecatedButton') {
return context.report(node, item.loc, `Do not use ${item.local.name}`);
}
return null;
});
},
};
},
};

Try running ESLint against a specific file (“eslint YourComponent.jsx” or against a directory “eslint src” and you should start to see it notice the new rule. If you are using an ESLint extension for VS Code, you may need to restart the editor.

For some other great examples of custom rules, check the source of other popular ESLint configurations. The example we made above is very basic, but provides the groundwork for adding any other rules you’d like.

Enforce Refactoring

Now we have a custom rule, but we can take this a step further. It would be great to actually prevent a file that has linting errors in it from being committed. This is possible with just a few additional changes.

Let’s install two NPM modules: “lint-staged” and “husky”.

npm install --save-dev lint-staged husky

In your main project package.json file, add the following lines:

..."husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,jsx}": "eslint"
},
...

With this in place, the eslint command will be run on any files that are staged for commit on a pre-commit hook. This means that if there are any linting errors in the files that are staged, the commit will not be successful until those errors are fixed. This forces refactoring on the spot and prevents the very popular tradition of adding TODO comments to come back and fix later.

Keep in mind that there is an escape hatch to allow committing, despite errors. Just add a “— no-verify” flag to your commit command. This should not be used often, if at all.

You may also want to configure different error and warning states depending on environment. For example, if you know there are many refactors all over your codebase, you do not want to be throwing errors when trying to deploy code: you only want those errors visible when working locally.

Summary

Custom linting rules are a great way to put unique standards into place in your project. And by adding pre-commit hooks, there’s an enforcement mechanism to ensure that tech debt is not put off indefinitely.

If you enjoyed this article, you might like some of the other posts I’ve been writing on my blog.

--

--