Writing custom ESLint rules without publishing to NPM
ESLint ships with plenty of useful rules out of the box, and many more come in the form of plugins—but often times it can be helpful to enforce rules specific to a project.
Most advice online recommends bundling these project-specific rules into an ESLint plugin and adding that plugin to your project’s dependencies. But publishing a package to NPM and bumping a version is a bit of a drag.
Here’s how to write custom ESLint rules that live in the repository you’re linting.
First, pick a folder. For the sake of example, let’s say you choose
config/eslint
, making your project look something like
this:
.eslintrc.json
package.json
config/
eslint/
Let’s create the “root” of your ESLint plugin. Create a file at
config/eslint/index.js
, and place this inside:
/* config/eslint/index.js */
const projectName = "your-project-name"
const fs = require("fs")
const path = require("path")
const ruleFiles = fs
.readdirSync("lib/eslint")
.filter(file => file !== "index.js" && !file.endsWith("test.js"))
const configs = {
all: {
plugins: [projectName],
rules: Object.fromEntries(
ruleFiles.map(file => [
`${projectName}/${path.basename(file, ".js")}`,
"error",
]),
),
},
}
const rules = Object.fromEntries(
ruleFiles.map(file => [path.basename(file, ".js"), require("./" + file)]),
)
module.exports = { configs, rules }
Ensure you replace your-project-name
with the name of
your project. This will determine the name of your ESLint plugin.
This index.js
file is the entrypoint to your ESLint
plugin as well as your ESLint config. An ESLint config
is what allows you to add something like
react/recommended
to the extends
section of
your ESLint config.
Let’s “install” this local NPM package by adding the following to
package.json
:
{
// ...
"devDependencies": {
// ...
"eslint-config-<your-project-name>": "link:./config/eslint",
"eslint-plugin-<your-project-name>": "link:./config/eslint"
}
}
Run yarn install
or npm install
to link
everything up.
Now you’re free to add the following to your
.eslintrc.json
(or whatever config file format you’re
using):
{
// ...
"extends": [
// ...
"plugin:<your-project-name>/all"
]
}
That’s it for the setup—ESLint will now use whatever rules you define in
config/eslint
.
Writing and testing a simple rule
Just for kicks, let’s write a rule to make sure things are working.
Let’s write one that bans variables named stevenPetryk
—a
wise thing to avoid.
Create a new rule at config/eslint/no-stevenpetryk.js
.
module.exports = {
create(context) {
return {
'Identifier[name="stevenPetryk"]': function(node) {
context.report({
node,
message: "Don't invoke his name.",
})
},
}
},
}
We can ensure it works by writing a test. ESLint ships with a
RuleTester
class that makes it easy to say “this code
should be valid, this code should be invalid”. It works with any
environment that exposes an assert
global, including Jest.
Create a test at config/eslint/no-stevenpetryk.test.js
:
const noUnusedOwnProps = require("./no-stevenpetryk")
const { RuleTester } = require("eslint")
const ruleTester = new RuleTester()
ruleTester.run("no-stevenpetryk", noUnusedOwnProps, {
valid: [{ code: `console.log(steven)` }],
invalid: [
{ code: `console.log(stevenPetryk)`, errors: [{ message: /don't/i }] },
],
})
Writing more complex rules
Now that you’ve written a simple rule, it’d be really handy to read the docs on writing custom ESLint rules:
ESLint’s selectors are powered by
esquery’s
query
function, which can be called on any AST node you’re
working with. This is something the ESLint docs don’t seem to mention.
Since esquery
is depended on by ESLint, you can
const { query } = require('esquery')
at the top of any
rules you write and it should “just work”.
Closing thoughts
With great power comes great responsibility:
- Keep your rules productivity-oriented.
- Keep your error messages kind and helpful.
- Write autofixers when possible.
Steven Petryk
stevenpetryk ↗️