Writing Custom ESLint Rules

Writing ESLint rules that are specific to your codebase — when existing plugins don't cover your architectural decisions.

March 22, 20266 min read2 / 3

The rules you can install from npm cover general-purpose patterns. The rules that matter most in a large codebase are the ones specific to your project — "don't style a raw HTML button when we have a <Button> component," "always use our date formatting utility instead of calling Date.toLocaleDateString directly."

Those rules don't exist in any plugin. You write them yourself. And once you do, they become some of the highest-leverage tools you have.

How ESLint Rules Work

ESLint parses your code into an Abstract Syntax Tree (AST) — a structured representation of what the code does, not what it looks like as text. Your rule is a visitor: it declares which AST node types it cares about, and ESLint calls your handler when it encounters them.

A simple rule that bans console.log:

JavaScript
// rules/no-console-log.js module.exports = { meta: { type: 'problem', docs: { description: 'Disallow console.log in production code', }, messages: { noConsoleLog: 'Use our logger utility instead of console.log.', }, }, create(context) { return { CallExpression(node) { if ( node.callee.type === 'MemberExpression' && node.callee.object.name === 'console' && node.callee.property.name === 'log' ) { context.report({ node, messageId: 'noConsoleLog', }); } }, }; }, };

This is the core structure of every custom rule: meta (documentation and configuration) and create (the visitor).

AST Explorer

The hardest part of writing custom rules is figuring out what the AST looks like for the code you're trying to match. AST Explorer solves this.

Go to astexplorer.net, set the parser to @typescript-eslint/parser, paste in some example code, and explore the tree. When you hover over code in the left pane, it highlights the corresponding AST node in the right pane.

For example, paste in:

TSX
import { Button } from '../../../components/Button';

And you'll see it becomes an ImportDeclaration node with a source property containing the path string. Your rule targets ImportDeclaration and checks node.source.value.

This is the workflow: write the code you want to flag in AST Explorer, understand its structure, write the visitor.

A Real Example: Enforcing Design System Components

Here's a rule that reports when someone uses a raw <button> element instead of the design system's <Button> component:

JavaScript
// rules/use-design-system-button.js module.exports = { meta: { type: 'suggestion', docs: { description: 'Use <Button> from the design system, not raw <button>', }, messages: { useButton: 'Use <Button> from @myapp/ui instead of a raw <button> element.', }, }, create(context) { return { JSXOpeningElement(node) { if ( node.name.type === 'JSXIdentifier' && node.name.name === 'button' ) { context.report({ node, messageId: 'useButton', }); } }, }; }, };

This catches every raw <button> tag. You can refine it — maybe you only want to flag it in certain directories, or only when there's no role attribute (which might indicate an intentional use). The AST gives you enough information to make those distinctions.

Adding Auto-Fix

Rules that just report are useful. Rules that also fix are better:

JavaScript
create(context) { return { ImportDeclaration(node) { if (node.source.value === 'lodash') { context.report({ node, message: 'Use lodash-es for tree-shaking.', fix(fixer) { return fixer.replaceText(node.source, "'lodash-es'"); }, }); } }, }; },

With a fix function, running eslint --fix replaces 'lodash' with 'lodash-es' automatically. Auto-fixable rules are particularly useful when you're migrating a large codebase — you can write the rule, run the fix across the whole codebase, and commit the result.

Packaging Local Rules

In a monorepo, I keep custom rules in a local package:

Plain text
packages/eslint-rules/ rules/ use-design-system-button.js no-direct-store-access.js index.js
JavaScript
// packages/eslint-rules/index.js module.exports = { rules: { 'use-design-system-button': require('./rules/use-design-system-button'), 'no-direct-store-access': require('./rules/no-direct-store-access'), }, };

Then in eslint.config.js:

JavaScript
import localRules from '@myapp/eslint-rules'; export default [ { plugins: { '@myapp': localRules }, rules: { '@myapp/use-design-system-button': 'error', }, }, ];

This keeps custom rules versioned alongside the codebase they apply to, and makes them available to all packages in the monorepo.

Husky + lint-staged

Rules that only run on CI are rules that developers learn to ignore. Running them on commit changes the feedback loop from "found out when CI failed after pushing" to "found out immediately when I tried to commit."

Bash
pnpm add -D husky lint-staged npx husky init

.husky/pre-commit:

Bash
npx lint-staged

package.json:

JSON
{ "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"] } }

lint-staged runs ESLint only on the files you've staged — not the entire codebase — so it's fast enough to not be annoying.

Biome and Oxlint

Worth mentioning: Biome and Oxlint are faster alternatives to ESLint for the standard rules. Biome in particular is a unified formatter + linter written in Rust, with dramatically faster execution than ESLint.

The tradeoff: they don't support custom rules (or support them with significant friction). For standard lint rules and formatting, they're worth considering. For architectural enforcement with custom rules specific to your codebase, ESLint is still the tool.

In practice, some teams run Biome for formatting and common rules (fast feedback), and ESLint for architectural rules (the rules that actually matter for your specific codebase). Not the most elegant setup, but it reflects the reality that the tools have different strengths.

The Investment

Writing custom ESLint rules takes longer than installing a plugin. It requires understanding the AST, which has a learning curve. And maintaining rules as the codebase evolves is real work.

But the rules you write are the ones that encode decisions that actually matter for your project — the ones no external plugin would ever know to check. And once they're in place, they run thousands of times per week across every developer's machine and every CI run, silently enforcing decisions that would otherwise require code review vigilance.

That asymmetry — small upfront investment, enormous ongoing leverage — is what makes custom rules worth the effort.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.