Skip to content

A little — every day.

ESLint rules that fix things themselves: fixers, suggestions and Angular templates

10 min read

On this page
In short
In the previous post, custom-eslint-rules, the rule only found the mistake. The next step is for it to fix it. The main thing here isn’t technique but a boundary: eslint --fix can be trusted only with a fix that never changes the code’s behaviour. Everything else is a suggestion that a person applies themselves. Plus rules for Angular templates: they work the same way, only their tree is different.

Three levels of help

A rule can do one of three things:

LevelWhat it doesWho decidesWhen it’s applied
Message onlyHighlights the mistakeA person fixes it by hand—
SuggestionOffers 1+ fix optionsA person picks one in the editorOn click; --fix does not touch them
FixFixes it itselfThe machineeslint --fix, “fix on save”, CI

There’s one rule for choosing: fix only if the fix is safe in 100 % of cases. If it can change behaviour in even one case, it’s a suggestion. --fix often runs on its own — on file save or in a pre-commit hook. Nobody looks at each of its edits, so a bad autofix is a bug nobody noticed.

Fix: the RxJS import path

The task. Since RxJS 7.2, operators are imported from 'rxjs', and the 'rxjs/operators' path is deprecated and will go away in one of the next major versions. A large project has hundreds of such imports — fixing them by hand is tedious, and it’s perfect work for --fix.

At first it seems simple: replace the string 'rxjs/operators' with 'rxjs'. But the official RxJS guide has a detail: several old operators (merge, concat, zip, race and others) remain only in 'rxjs/operators'. And 'rxjs' has a different function under the same name — a static one, not an operator. A naive autofix would silently change the meaning of the code, and TypeScript might not even notice.

So the rule splits the cases in two:

// tools/lint-rules/rules/rxjs-operators-import.js
// Эти имена в 'rxjs' — другие функции (статические), а не операторы из 'rxjs/operators'.
// Их нельзя чинить заменой пути: смысл кода изменится.
const SAME_NAME_DIFFERENT_FUNCTION = new Set([
  'combineLatest', 'concat', 'merge', 'onErrorResumeNext', 'partition', 'race', 'zip',
]);

export default {
  meta: {
    type: 'suggestion',
    fixable: 'code',
    docs: { description: 'Операторы импортируются из rxjs, а не из rxjs/operators' },
    messages: {
      deprecated: "Импортируй из 'rxjs': путь 'rxjs/operators' устарел с RxJS 7.2.",
      manual:
        "'{{name}}' в 'rxjs' — другая функция с тем же именем. Замени вручную (для merge, concat, zip, race — на mergeWith и т.п.).",
    },
    schema: [],
  },
  create(context) {
    return {
      'ImportDeclaration[source.value="rxjs/operators"]'(node) {
        const risky = node.specifiers.find(
          (s) => s.type === 'ImportSpecifier' && SAME_NAME_DIFFERENT_FUNCTION.has(s.imported.name),
        );

        if (risky) {
          // сообщаем, но НЕ чиним автоматически
          context.report({ node: risky, messageId: 'manual', data: { name: risky.imported.name } });
          return;
        }

        context.report({
          node: node.source,
          messageId: 'deprecated',
          fix(fixer) {
            const quote = node.source.raw[0]; // сохраняем стиль кавычек
            return fixer.replaceText(node.source, `${quote}rxjs${quote}`);
          },
        });
      },
    };
  },
};

What’s new here compared with the previous post:

  1. meta.fixable: 'code' — without this field ESLint refuses to apply the fix. It’s a guard against rules that fix things “by accident”.
  2. fix(fixer) inside context.report. The function receives fixer — a set of safe text operations: replaceText, insertTextBefore, insertTextAfter, remove and their Range versions. We don’t write the file ourselves — we describe the edit, and ESLint applies it.
  3. We keep the code style. node.source.raw[0] is the quote character used in the source. An autofix shouldn’t argue with Prettier.
  4. The dangerous case gets no fix. If the import contains one of the “double” names, the rule only reports. The mistake is visible, but the decision stays with a person.

Point 4 is the main lesson of the post. The rule’s code grew by 15 lines, but now --fix can be run safely across the whole repository.

Suggestion: <button> without type

Now a template. A silent bug from HTML: <button> defaults to type="submit". If a button is inside a <form> and has no type, clicking it submits the form — even if the button was only meant to open a dropdown.

Can this be fixed automatically? No. We don’t know what the author intended: a plain button (type="button") or submitting the form (type="submit"). So this is a suggestion with two options.

An honest note on the example
angular-eslint already has a ready-made rule, @angular-eslint/template/button-has-type. In a real project, turn that on — it’s step 1 from the previous post. I write my own here only because it’s short and shows well how template rules are built.

How ESLint reads a template

ESLint doesn’t understand Angular HTML templates by itself. The @angular-eslint/template-parser parser does that: it takes the real Angular compiler (@angular/compiler) and turns the template into a tree. The idea is the same as with TypeScript, only the nodes are different:

<button (click)="save()">Сохранить</button>

Element  name="button"
├── attributes: []                  ← обычные атрибуты: type="button"
├── inputs:     []                  ← привязки: [type]="kind", [attr.type]="kind"
├── outputs:    [BoundEvent click]  ← события: (click)="save()"
└── children:   [Text "Сохранить"]

The new syntax is in the tree too: IfBlock, ForLoopBlock, SwitchBlock, DeferredBlock. So the rule will find a button inside @if { … } as well.

The rule

// tools/lint-rules/rules/button-has-type.js
import { getTemplateParserServices } from '@angular-eslint/utils';

export default {
  meta: {
    type: 'problem',
    hasSuggestions: true,
    docs: { description: 'У <button> должен быть явный type' },
    messages: {
      missing: 'Укажи type у <button>: внутри <form> без него кнопка отправляет форму.',
      addButton: 'Добавить type="button" (просто кнопка)',
      addSubmit: 'Добавить type="submit" (отправка формы)',
    },
    schema: [],
  },
  create(context) {
    const parserServices = getTemplateParserServices(context);

    return {
      'Element[name="button"]'(node) {
        const hasType =
          node.attributes.some((attr) => attr.name === 'type') || // type="..."
          node.inputs.some((input) => input.name === 'type');     // [type]="..." или [attr.type]

        if (hasType) return;

        // позиция сразу после "<button"
        const start = node.startSourceSpan.start.offset;
        const range = [start, start + '<button'.length];
        const insert = (value) => (fixer) =>
          fixer.insertTextAfterRange(range, ` type="${value}"`);

        context.report({
          loc: parserServices.convertNodeSourceSpanToLoc(node.startSourceSpan),
          messageId: 'missing',
          suggest: [
            { messageId: 'addButton', fix: insert('button') },
            { messageId: 'addSubmit', fix: insert('submit') },
          ],
        });
      },
    };
  },
};

Differences from the TypeScript rule:

  1. getTemplateParserServices(context) — a helper from @angular-eslint/utils. Template nodes don’t have the usual loc that TS nodes have; they have sourceSpan from the Angular compiler. convertNodeSourceSpanToLoc converts one into the other so ESLint highlights the right place.
  2. We compute offsets ourselves. startSourceSpan.start.offset is the position of the < character in the file. We insert text right after <button, so we use insertTextAfterRange.
  3. meta.hasSuggestions: true instead of fixable. Each option in suggest has its own messageId (what a person sees in the editor menu) and its own fix function.
  4. We check both forms of type: the static attribute (attributes) and the binding (inputs). Forget about [type]="kind", and the rule will complain about correct code — and it’ll soon be switched off.

Tests: output for the fix and for each suggestion

In RuleTester fixes have their own field. For a fix — output: what the code should become after --fix. For a suggestion — output inside each suggestion. And output: null means “there must be no fix” — that’s how the dangerous case is tested.

// tools/lint-rules/rules/autofix.test.js
import { RuleTester } from 'eslint';
import tsParser from '@typescript-eslint/parser';
import templateParser from '@angular-eslint/template-parser';
import rxjsRule from './rxjs-operators-import.js';
import buttonRule from './button-has-type.js';

new RuleTester({ languageOptions: { parser: tsParser } }).run('rxjs-operators-import', rxjsRule, {
  valid: ["import { map } from 'rxjs';"],
  invalid: [
    { code: "import { map } from 'rxjs/operators';", output: "import { map } from 'rxjs';", errors: [{ messageId: 'deprecated' }] },
    { code: 'import { map } from "rxjs/operators";', output: 'import { map } from "rxjs";', errors: [{ messageId: 'deprecated' }] },
    { code: "import { map, merge } from 'rxjs/operators';", output: null, errors: [{ messageId: 'manual' }] },
  ],
});

new RuleTester({ languageOptions: { parser: templateParser } }).run('button-has-type', buttonRule, {
  valid: [
    '<button type="button">Ок</button>',
    '<button [type]="kind">Ок</button>',
    '<button [attr.type]="kind">Ок</button>',
  ],
  invalid: [
    {
      code: '<form><button (click)="save()">Сохранить</button></form>',
      errors: [{
        messageId: 'missing',
        suggestions: [
          { messageId: 'addButton', output: '<form><button type="button" (click)="save()">Сохранить</button></form>' },
          { messageId: 'addSubmit', output: '<form><button type="submit" (click)="save()">Сохранить</button></form>' },
        ],
      }],
    },
    {
      code: '@if (ready) { <button>Ок</button> }',
      errors: [{
        messageId: 'missing',
        suggestions: [
          { messageId: 'addButton', output: '@if (ready) { <button type="button">Ок</button> }' },
          { messageId: 'addSubmit', output: '@if (ready) { <button type="submit">Ок</button> }' },
        ],
      }],
    },
  ],
});

The output: null test for merge is, for me, the most important one in the whole file. It guarantees that even if someone later “simplifies” the rule, the dangerous autofix won’t come back unnoticed.

Wiring it up

// eslint.config.js (фрагмент)
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import rxjsOperatorsImport from './tools/lint-rules/rules/rxjs-operators-import.js';
import buttonHasType from './tools/lint-rules/rules/button-has-type.js';

export default [
  {
    files: ['**/*.ts'],
    languageOptions: { parser: tseslint.parser },
    processor: angular.processInlineTemplates,
    plugins: { local: { rules: { 'rxjs-operators-import': rxjsOperatorsImport } } },
    rules: { 'local/rxjs-operators-import': 'error' },
  },
  {
    files: ['**/*.html'],
    languageOptions: { parser: angular.templateParser },
    plugins: { 'local-template': { rules: { 'button-has-type': buttonHasType } } },
    rules: { 'local-template/button-has-type': 'error' },
  },
];

The processor: angular.processInlineTemplates line is needed for components with template: '…' right in the .ts file. The processor cuts such a template out into a virtual .html file, and the second config block applies to it. One rule works both for separate .html files and for inline templates — I checked both.

What you see in the terminal:

$ npx eslint --fix src
src/a.component.html
  1:7   error  Укажи type у <button>: внутри <form> без него кнопка отправляет форму

src/a.component.ts
  3:40  error  Укажи type у <button>: внутри <form> без него кнопка отправляет форму

✖ 2 problems (2 errors, 0 warnings)

The RxJS import in a.component.ts was fixed silently. Both buttons — in .html and in the inline template — remained: --fix doesn’t apply suggestions, and that’s right.

Trade-offs and holes

  • An autofix edits text, not meaning. ESLint doesn’t re-check whether the code compiles after the edit. After a mass --fix, always run tsc and the tests.
  • Several edits in one place. If two rules want to change the same span, ESLint applies one and goes over the file again (up to 10 passes). Usually this works, but rules that “argue” are better not enabled together.
  • The list of “dangerous” names is manual. If a future RxJS version adds another such name, the rule won’t know about it. That’s why the list sits at the top of the file with a comment saying where it came from.
  • Suggestions are only visible in the editor. In CI it’s an ordinary error. For a team that’s fine: CI says “there’s a problem”, the editor helps solve it.

If you want to repeat it

  1. Ask the question: can this fix change behaviour even once? Yes → suggestion. No → fix.
  2. Find the exceptions before writing code. Read the official documentation of what you’re fixing: for RxJS the exceptions are described right in the import guide.
  3. Test output for every edit and output: null for cases where there must be no edit.
  4. For templates, look at the tree through the angular-eslint parser and remember both forms of an attribute: static and binding.
  5. After the first mass --fix — tsc, tests and a separate commit, so it’s easy to roll back.

What’s next

Both rules so far look only at syntax: names, attributes, paths. The next level is rules with type information (typed linting in typescript-eslint). For example, “is this really an Observable?” instead of “the method is called subscribe”. It’s slower, but it removes the false positives from the previous post.

Sources