Steven Petryk

I'm a software engineer living and learning in San Francisco, California.

Writing custom ESLint rules without publishing to NPM

Published

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:

Steven Petryk

twitter find me on twitter stevenpetryk ↗️