Your own ESLint rules as a guard against repeated mistakes
On this page
Why review isn’t enough
Review is done by a person. People get tired, rush, look at the logic and miss the small thing they’ve already seen ten times. And a new developer on the team doesn’t know about that small thing at all: it lives only in the heads of those who once fixed production because of it.
A linter works differently:
- It doesn’t get tired. The hundredth file is checked the same way as the first.
- It fires earlier. The mistake shows up in the editor as you type, not a day later in an MR comment.
- It keeps the knowledge. A rule with a clear message is documentation that can’t go unread.
That’s why every rule I have is an answer to a real bug. Not “it’s prettier that way” but “this is where we already got burned”.
The ladder: from cheap to expensive
Your own rule is the most expensive step. It has to be written, tested and maintained. So first I check the two steps below it.
Step 1. An existing rule
Almost always someone has already stepped on the same rake. Where to look:
- ESLint’s built-in rules;
typescript-eslint— type-related mistakes (for example, a forgottenawaitis caught byno-floating-promises);angular-eslint— components, templates, lifecycle;- in an Nx monorepo —
@nx/enforce-module-boundaries: it stops libraries from importing what they’re not supposed to.
Turning on an existing rule takes five minutes. Writing your own takes an hour or more.
Step 2. A ban with an explanation
If there’s no existing rule but the mistake looks like “you mustn’t use X”, the built-in no-restricted-* are enough. An example: in an app with SSR (server-side rendering), direct access to localStorage breaks rendering, because it doesn’t exist on the server. So everyone goes through a wrapper service:
// eslint.config.js (фрагмент)
{
files: ['apps/**/*.ts', 'libs/**/*.ts'],
ignores: ['libs/shared/storage/**'], // здесь живёт сама обёртка
rules: {
'no-restricted-globals': ['error', {
name: 'localStorage',
message: 'Используй StorageService: при SSR localStorage нет.',
}],
'no-restricted-properties': ['error', {
object: 'window',
property: 'localStorage',
message: 'Используй StorageService: при SSR localStorage нет.',
}],
},
}Note the second rule. The first catches localStorage.getItem() but misses window.localStorage.getItem(). Every ban has to be checked for workarounds, or it gives a false sense of safety.
For slightly more complex cases there’s no-restricted-syntax: it takes a selector over the code tree (more on that below) and a message. That’s almost your own rule, just without a separate file.
Step 3. Your own rule
Needed when the check depends on context: “calling X is fine only if Y is next to it”. A selector can’t express that; you need logic.
How a linter sees code
Before writing a rule you need to understand one idea. ESLint doesn’t read code as text. It first turns it into an AST — an abstract syntax tree. Every construct becomes a node with a type and fields.
For example, the line this.items$.subscribe() is:
CallExpression ← вызов функции
└── callee: MemberExpression ← обращение «объект.свойство»
├── object: this.items$
└── property: subscribeA rule is a function that tells ESLint: “when you walk the tree and meet a node of such-and-such type, call me”. You can see the tree of any code in the typescript-eslint playground or on astexplorer.net (links at the end). I always start there: paste an example with the mistake and see which nodes need catching.
Taking apart your own rule
A teaching task. In an Angular component, a subscription via subscribe() without takeUntilDestroyed() outlives the component: the component is destroyed, but the subscription keeps running and using memory. The mistake is silent, invisible in tests, and shows up after half an hour of the app running.
What we catch: a .subscribe() call not preceded by .pipe(..., takeUntilDestroyed(...)).
// tools/lint-rules/rules/subscribe-needs-destroy.js
export default {
meta: {
type: 'problem',
docs: {
description: 'subscribe() в компоненте должен идти после takeUntilDestroyed()',
},
messages: {
missing:
'Добавь takeUntilDestroyed() в pipe() перед subscribe(), иначе подписка переживёт компонент.',
},
schema: [],
},
create(context) {
return {
// селектор: любой вызов, где имя свойства — subscribe
'CallExpression[callee.property.name="subscribe"]'(node) {
const target = node.callee.object; // то, у чего вызвали subscribe
const isPipe =
target.type === 'CallExpression' &&
target.callee.type === 'MemberExpression' &&
target.callee.property.name === 'pipe';
const hasDestroy =
isPipe &&
target.arguments.some(
(arg) =>
arg.type === 'CallExpression' &&
arg.callee.name === 'takeUntilDestroyed',
);
if (!hasDestroy) {
context.report({ node: node.callee.property, messageId: 'missing' });
}
},
};
},
};Piece by piece:
meta— the rule’s description. The most important part ismessages: the text the developer will see. A good message answers two questions: what to do and why. People switch off a bare “Forbidden” with a comment.createreturns an object: the key is a node selector, the value a function. ESLint calls it for every matching node.- The check goes backwards from
subscribe: what comes before it? If it isn’tpipe(...), or there’s notakeUntilDestroyedinsidepipe, we report. context.reporthighlights exactly the wordsubscribe, not the whole line — so the mistake is visible at once.
Wiring it into the flat config:
// eslint.config.js (фрагмент)
import subscribeNeedsDestroy from './tools/lint-rules/rules/subscribe-needs-destroy.js';
export default [
// ...базовый конфиг с парсером typescript-eslint
{
files: ['**/*.component.ts'], // в сервисах другие правила жизни подписок
plugins: {
local: { rules: { 'subscribe-needs-destroy': subscribeNeedsDestroy } },
},
rules: { 'local/subscribe-needs-destroy': 'error' },
},
];The files field is no small thing here. A rule that fires where it isn’t needed quickly gets switched off — and trust in the whole linter goes with it.
The test: code with the bug is the test
A rule without tests breaks on the first refactor. ESLint has RuleTester for this: you give it examples without the mistake (valid) and examples with it (invalid).
// tools/lint-rules/rules/subscribe-needs-destroy.test.js
import { RuleTester } from 'eslint';
import tsParser from '@typescript-eslint/parser';
import rule from './subscribe-needs-destroy.js';
const tester = new RuleTester({ languageOptions: { parser: tsParser } });
tester.run('subscribe-needs-destroy', rule, {
valid: [
'this.items$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe();',
'this.items$.pipe(map(toView), takeUntilDestroyed()).subscribe();',
],
invalid: [
{ code: 'this.items$.subscribe();', errors: [{ messageId: 'missing' }] },
{ code: 'this.items$.pipe(map(toView)).subscribe();', errors: [{ messageId: 'missing' }] },
],
});My order: first I put a simplified copy of the code that actually broke into invalid. Then I write the rule until the test goes green. It’s ordinary TDD, only the thing under test is the linter itself.
Trade-offs and holes
Honestly, here’s what this rule can’t do:
- It’s a heuristic, not type analysis. The rule looks at names. Call
subscribeon something that isn’t an Observable and you get a false positive. WraptakeUntilDestroyedin your own function and the rule won’t recognise it. - There are legitimate exceptions. For example, an HTTP request that completes by itself. For such places —
// eslint-disable-next-linewith an explanation in a comment. If exceptions multiply, the rule is worded imprecisely. - Not everything should be caught by a linter. If the mistake is about business logic rather than the shape of the code, it belongs in tests, not in ESLint.
How to tell the rule works
Without numbers it’s just a feeling. The minimum I count:
- First run. How many violations the rule found in existing code. If zero, it’s probably looking for the wrong thing, or the bug was a one-off.
- Gradual rollout. First
warn, to fix the old code without blocking CI, thenerror. - Repeats. Did this mistake reach review or production after the rule was turned on.
Counting the violations of one rule:
npx eslint . -f json \
| jq '[.[].messages[] | select(.ruleId == "local/subscribe-needs-destroy")] | length'If you want to repeat it
- Keep a list of repeats. Every time you write the same review comment a second time, note it down.
- Climb the ladder: an existing rule →
no-restricted-*with a message → your own rule. - Start with a test: code with the real mistake in
invalid. - Write the message for a newcomer: what to do and why.
- Limit
filesto where the rule makes sense. - Turn it on via
warn, count the violations, thenerror.
What’s next
The next step is rules with autofix (fixer), so that eslint --fix fixes the mistake by itself, and rules for Angular templates via the angular-eslint parser. More on that in the post eslint-fixer-angular-templates.