Build Custom ESLint Rules to Enforce Codebase-Specific Patterns
A practical guide to writing, testing, and shipping custom ESLint rules with autofix. Covers AST visitors, RuleTester, flat config, and real-world examples from TypeScript codebases.
Build Custom ESLint Rules to Enforce Codebase-Specific Patterns
Off-the-shelf ESLint rules cover general best practices, but every codebase has its own patterns — internal APIs that must be called a certain way, deprecated prop names that need migration, test conventions that keep slipping in PR review. Writing a custom rule turns those review comments into automated enforcement.
This guide walks through building a custom ESLint rule from scratch, testing it, adding autofix, and shipping it as a plugin. You’ll end with a reusable template you can adapt to any pattern in your codebase.
Why Write Custom Rules?
Three scenarios where a custom rule pays for itself in the first week:
Scenario 1 — API migration. Your team renamed createClient({ token }) to createClient({ apiKey }). A custom rule catches every old call site and autofixes it. No manual grep-and-replace, no missed files.
Scenario 2 — Test hygiene. Your team agreed that Enzyme shallow() calls must include the component types as a generic (shallow<User>(<User />)). A custom rule enforces it and adds the generic automatically [1].
Scenario 3 — Dependency protection. Someone keeps importing lodash directly instead of the tree-shakable lodash-es. A custom rule catches import ... from 'lodash' and suggests the correct import path.
Compare approaches:
| Approach | Effort | Durability | Catches all cases? |
|---|---|---|---|
| Manual PR review | Zero setup | Fades after team churn | Depends on reviewer |
| grep + manual fix | ~5 min per search | One-time only | Misses renamed calls |
| Custom ESLint rule | ~2 hours initial | Lasts forever | Yes, with autofix |
A custom rule pays its development cost in the first week on a team of 3+ developers [4].
In each case, the rule encodes a team decision into a machine-enforceable check. PR reviewers stop repeating the same comments. New contributors learn the patterns instantly.
Anatomy of an ESLint Rule
Every rule has two parts: meta (metadata) and create (visitors):
// eslint-plugin-codeintel/rules/no-raw-string-in-jsx.js
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Disallow raw strings in JSX — use i18n function instead",
},
fixable: "code", // Autofix supported
schema: [], // No options
messages: {
rawString: "Use t() instead of raw strings in JSX.",
},
},
create(context) {
return {
JSXText(node) {
const raw = node.value.trim();
if (raw.length > 0 && !/^[<>{}()\[\]\s.,!?;:-]+$/.test(raw)) {
context.report({
node,
messageId: "rawString",
fix(fixer) {
return fixer.replaceText(node, `{t("${raw}")}`);
},
});
}
},
};
},
};
The create function returns an object whose keys are AST node types — JSXText, CallExpression, VariableDeclarator, etc. ESLint walks the AST and calls your visitor every time it hits a matching node [2].
Finding the Right Node Type
Use AST Explorer to discover node types. Paste a snippet of your target code, select the right parser (e.g., @typescript-eslint/parser for TypeScript), and inspect the tree:
// Input: shallow(<User />)
// AST structure:
CallExpression
callee: Identifier (name: "shallow")
typeParameters: null <-- this is what we detect
arguments: [JSXElement]
openingElement: JSXIdentifier (name: "User")
The node type and property path tell you exactly which visitor to write [3].
Writing Your First Rule: Enforce Import Hygiene
Let’s build a rule that blocks direct lodash imports and suggests lodash-es:
module.exports = {
meta: {
type: "problem",
docs: {
description: "Prefer lodash-es over lodash for tree shaking",
},
fixable: "code",
schema: [],
messages: {
useLodashEs:
"Use 'lodash-es' instead of 'lodash' for better tree shaking.",
},
},
create(context) {
return {
ImportDeclaration(node) {
if (node.source.value === "lodash") {
context.report({
node,
messageId: "useLodashEs",
fix(fixer) {
return fixer.replaceText(node.source, '"lodash-es"');
},
});
}
},
};
},
};
That’s 27 lines. It catches import _ from 'lodash', import { map } from 'lodash', and require('lodash') via the ImportDeclaration/CallExpression visitors.
Testing with RuleTester
ESLint ships a RuleTester class. Tests define valid and invalid code samples:
const { RuleTester } = require("eslint");
const rule = require("./no-lodash");
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});
ruleTester.run("no-lodash", rule, {
valid: [
{ code: `import { map } from 'lodash-es';` },
{ code: `import _ from 'underscore';` },
],
invalid: [
{
code: `import { map } from 'lodash';`,
output: `import { map } from 'lodash-es';`,
errors: [{ messageId: "useLodashEs" }],
},
],
});
console.log("All tests passed.");
Run with node test.js. The output field verifies the autofix produces exactly the right result — no more, no less [4].
Autofix: Precise Surgery Only
The fixer API has four methods. Use the most specific one:
| Method | Use case |
|---|---|
fixer.replaceText(node, text) | Replace an entire node |
fixer.insertTextAfter(node, text) | Add text after a node |
fixer.insertTextBefore(node, text) | Add text before a node |
fixer.remove(node) | Delete a node entirely |
Rule of thumb: make the smallest possible replacement. Replace the source string, not the whole import statement. Smaller fixes don’t collide with other rules in the same pass [2].
// GOOD: precise replacement
fixer.replaceText(node.source, '"lodash-es"');
// BAD: replaces the whole statement
fixer.replaceText(node, `import { map } from 'lodash-es';`);
Bundling Into a Plugin
Create an index.js that exports all rules:
// eslint-plugin-codeintel/index.js
const noLodash = require("./rules/no-lodash");
const noRawStringInJsx = require("./rules/no-raw-string-in-jsx");
module.exports = {
rules: {
"no-lodash": noLodash,
"no-raw-string-in-jsx": noRawStringInJsx,
},
};
Then in your project’s eslint.config.js (flat config):
import codeintel from "./eslint-plugin-codeintel/index.js";
export default [
{
plugins: { codeintel },
rules: {
"codeintel/no-lodash": "error",
"codeintel/no-raw-string-in-jsx": "warn",
},
},
];
How to Apply This
Step 1 — Identify the pattern. The next time you leave a “please use X instead of Y” comment in a PR, that’s your first rule candidate. Teams report spending 8-15 minutes per such comment during review [4].
Timeline comparison for a team of 5 over a 3-month sprint:
| Task | Manual review | Custom rule |
|---|---|---|
| Initial setup | 0 min | ~120 min |
| Per-review effort | 8-15 min per occurrence | 0 min (automatic) |
| Occurrences caught | Depends on reviewer attention | 100% |
| Migration (50 files) | 4-6 hours of grep+edit | eslint --fix . in 2 seconds |
Step 2 — Inspect the AST. Paste both valid and invalid code into AST Explorer. Note the node type and distinguishing properties.
Step 3 — Write the rule. Start with just context.report() — no autofix yet. Test with RuleTester.
Step 4 — Add autofix. Use fixer.replaceText or fixer.insertTextAfter. Verify the output field in your invalid test cases.
Step 5 — Ship as a plugin. Bundle in an eslint-plugin-* package. Reference it in your eslint config. Run eslint --fix across the codebase to apply all fixes at once.
Step 6 — Add to CI. Run eslint --max-warnings 0 in CI. New violations block the build.
Key Takeaways
- Custom rules automate team decisions — every repeated PR comment is a rule waiting to be written.
- AST Explorer is your debugger — paste code, find the node type, write the visitor. No guessing.
- Autofix should be minimal — replace only what needs changing. Smaller fixes compose better.
- RuleTester catches regressions — valid + invalid cases guarantee your rule works after refactors.
- Start small — a 20-line rule that catches one recurring bug is worth more than a 100-line rule that covers every edge case.
References
[1] Alexandre Gomes, “Writing custom TypeScript ESLint rules: How I learned to love the AST,” DEV Community, 2021. https://dev.to/alexgomesdev/writing-custom-typescript-eslint-rules-how-i-learned-to-love-the-ast-15pn
[2] ESLint, “Custom Rule Tutorial,” ESLint Docs. https://eslint.org/docs/latest/extend/custom-rule-tutorial
[3] ESLint, “Selectors,” ESLint Docs. https://eslint.org/docs/latest/extend/selectors
[4] Tony Ward, “Driving Consistency with Custom ESLint Rules,” Jun 2024. https://www.tonyward.dev/articles/driving-consistency-with-eslint-rules
[5] Sindre Sorhus, “eslint-plugin-unicorn,” GitHub. https://github.com/sindresorhus/eslint-plugin-unicorn
📖 Related Reads
- ToolBrain — tool reviews, LLM comparisons, and AI workflow guides
Cross-links automatically generated from CodeIntel Log.