Verification: —

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.

Contents

    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:

    ApproachEffortDurabilityCatches all cases?
    Manual PR reviewZero setupFades after team churnDepends on reviewer
    grep + manual fix~5 min per searchOne-time onlyMisses renamed calls
    Custom ESLint rule~2 hours initialLasts foreverYes, 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:

    MethodUse 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:

    TaskManual reviewCustom rule
    Initial setup0 min~120 min
    Per-review effort8-15 min per occurrence0 min (automatic)
    Occurrences caughtDepends on reviewer attention100%
    Migration (50 files)4-6 hours of grep+editeslint --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

    • ToolBrain — tool reviews, LLM comparisons, and AI workflow guides

    Cross-links automatically generated from CodeIntel Log.