Skip to content

Building a Custom ESLint Plugin

How to build custom ESLint rules for your team's conventions: AST selectors, rule testing, auto-fixers and publishing a shared plugin.

4 min read
Terminal output showing custom ESLint rule catching a team-specific code pattern violation with auto-fix suggestion

ESLint's built-in rules and popular plugins handle common code quality issues: unused variables, missing error handling, style consistency. But every team has conventions that no public plugin enforces. "Always use our custom logger instead of console.log." "Never call the analytics SDK outside of event handlers." "API route handlers must validate the request body using our schema validator."

Custom ESLint rules turn these team-specific conventions into automated checks. Instead of catching violations in code review, the editor catches them while typing. This guide builds a custom plugin from scratch, including rule writing, AST navigation, auto-fixing, and testing.

Understanding the AST

ESLint rules operate on Abstract Syntax Trees. When ESLint parses your code, it builds a tree where each node represents a syntactic element: function declarations, variable assignments, method calls, conditionals.

tstypescript
// This code:
console.log("hello");
 
// Produces this AST (simplified):
const ast = {
  type: "Program",
  body: [
    {
      type: "ExpressionStatement",
      expression: {
        type: "CallExpression",
        callee: {
          type: "MemberExpression",
          object: { type: "Identifier", name: "console" },
          property: { type: "Identifier", name: "log" },
        },
        arguments: [
          { type: "Literal", value: "hello" },
        ],
      },
    },
  ],
};
 
// ESLint rules are visitors that react to specific node types.
// When ESLint encounters a CallExpression node, it calls your
// rule's CallExpression handler with that node.

Setting Up the Plugin

shbash
# Project structure
mkdir eslint-plugin-ourteam && cd eslint-plugin-ourteam
npm init -y
npm install -D eslint @types/eslint typescript vitest
jsonjson
{
  "name": "eslint-plugin-ourteam",
  "version": "1.0.0",
  "main": "dist/index.js",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "test": "vitest"
  }
}
tstypescript
// src/index.ts — plugin entry point
import { noConsoleLog } from "./rules/no-console-log";
import { requireBodyValidation } from "./rules/require-body-validation";
import { useCustomLogger } from "./rules/use-custom-logger";
 
const plugin = {
  rules: {
    "no-console-log": noConsoleLog,
    "require-body-validation": requireBodyValidation,
    "use-custom-logger": useCustomLogger,
  },
};
 
export default plugin;

Writing Your First Rule

Rule: "Use our custom logger instead of console.log in production code."

tstypescript
// src/rules/use-custom-logger.ts
import { Rule } from "eslint";
 
export const useCustomLogger: Rule.RuleModule = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Enforce using @ourteam/logger instead of console methods",
    },
    fixable: "code",
    messages: {
      useLogger:
        "Use logger.{{method}}() from @ourteam/logger instead of console.{{method}}()",
    },
    schema: [],
  },
 
  create(context) {
    // Map console methods to logger methods
    const methodMap: Record<string, string> = {
      log: "info",
      info: "info",
      warn: "warn",
      error: "error",
      debug: "debug",
    };
 
    return {
      // This visitor fires for every MemberExpression node
      MemberExpression(node) {
        if (
          node.object.type === "Identifier" &&
          node.object.name === "console" &&
          node.property.type === "Identifier" &&
          node.property.name in methodMap
        ) {
          const consoleMethod = node.property.name;
          const loggerMethod = methodMap[consoleMethod];
 
          context.report({
            node,
            messageId: "useLogger",
            data: { method: consoleMethod },
            fix(fixer) {
              // Replace "console.log" with "logger.info"
              return fixer.replaceText(node, `logger.${loggerMethod}`);
            },
          });
        }
      },
    };
  },
};

A More Complex Rule: Require Body Validation

Rule: "Every Express route handler with a POST/PUT/PATCH method must call validateBody() before accessing req.body."

tstypescript
// src/rules/require-body-validation.ts
import { Rule } from "eslint";
import { Node } from "estree";
 
export const requireBodyValidation: Rule.RuleModule = {
  meta: {
    type: "problem",
    docs: {
      description: "Require validateBody() before accessing req.body in route handlers",
    },
    messages: {
      missingValidation:
        "req.body accessed without calling validateBody() first. " +
        "Add validateBody(schema) before using request body data.",
    },
    schema: [],
  },
 
  create(context) {
    return {
      // Match: app.post("/path", handler) or router.put("/path", handler)
      'CallExpression[callee.property.name=/^(post|put|patch)$/]'(
        node: Rule.Node
      ) {
        const callExpr = node as unknown as {
          arguments: Node[];
        };
 
        // Find the handler function (last argument)
        const handler = callExpr.arguments.at(-1);
        if (!handler) return;
        if (
          handler.type !== "ArrowFunctionExpression" &&
          handler.type !== "FunctionExpression"
        ) {
          return;
        }
 
        const body =
          handler.body.type === "BlockStatement"
            ? handler.body.body
            : [];
 
        let hasValidation = false;
        let reqBodyAccess: Rule.Node | null = null;
 
        for (const stmt of body) {
          // Check if validateBody is called
          const source = context.getSourceCode().getText(stmt as Rule.Node);
          if (source.includes("validateBody")) {
            hasValidation = true;
          }
 
          // Check if req.body is accessed
          if (!reqBodyAccess && source.includes("req.body")) {
            reqBodyAccess = stmt as Rule.Node;
          }
        }
 
        if (reqBodyAccess && !hasValidation) {
          context.report({
            node: reqBodyAccess,
            messageId: "missingValidation",
          });
        }
      },
    };
  },
};

Testing Rules

ESLint provides a RuleTester that makes testing rules straightforward. Each test case provides code and the expected errors (or lack thereof).

tstypescript
// src/rules/__tests__/use-custom-logger.test.ts
import { RuleTester } from "eslint";
import { useCustomLogger } from "../use-custom-logger";
import { describe, it } from "vitest";
 
const ruleTester = new RuleTester({
  parserOptions: { ecmaVersion: 2020, sourceType: "module" },
});
 
describe("use-custom-logger", () => {
  it("should enforce using custom logger", () => {
    ruleTester.run("use-custom-logger", useCustomLogger, {
      valid: [
        // These should NOT trigger the rule
        'logger.info("message")',
        'logger.error("failed", error)',
        'logger.warn("deprecated")',
        'someObject.log("this is fine")', // Not console
      ],
 
      invalid: [
        {
          code: 'console.log("hello")',
          errors: [{ messageId: "useLogger" }],
          output: 'logger.info("hello")', // Verify auto-fix output
        },
        {
          code: 'console.error("failed", err)',
          errors: [{ messageId: "useLogger" }],
          output: 'logger.error("failed", err)',
        },
        {
          code: 'console.warn("deprecated")',
          errors: [{ messageId: "useLogger" }],
          output: 'logger.warn("deprecated")',
        },
      ],
    });
  });
});
tstypescript
// ❌ Testing only the happy path
const weakTests = {
  valid: ['logger.info("ok")'],
  invalid: [
    { code: 'console.log("bad")', errors: [{ messageId: "useLogger" }] },
  ],
};
 
// ✅ Testing edge cases thoroughly
const thoroughTests = {
  valid: [
    'logger.info("message")',            // Correct usage
    'someObject.log("not console")',     // Different object
    'console.table(data)',               // Method not in our map
    'const console = {}; console.log()', // Shadowed console
  ],
  invalid: [
    // All console methods that should trigger
    { code: 'console.log("x")', errors: 1, output: 'logger.info("x")' },
    { code: 'console.info("x")', errors: 1, output: 'logger.info("x")' },
    { code: 'console.warn("x")', errors: 1, output: 'logger.warn("x")' },
    { code: 'console.error("x")', errors: 1, output: 'logger.error("x")' },
    { code: 'console.debug("x")', errors: 1, output: 'logger.debug("x")' },
    // Multiple violations in one file
    {
      code: 'console.log("a"); console.error("b")',
      errors: 2,
    },
  ],
};

Publishing and Using the Plugin

shbash
# Build the plugin
npm run build
 
# For internal teams: publish to your registry
npm publish --registry https://npm.internal.company.com
 
# Or install directly from git
npm install -D git+https://github.com/ourteam/eslint-plugin-ourteam.git
jsjavascript
// eslint.config.mjs — using the plugin in flat config
import ourteamPlugin from "eslint-plugin-ourteam";
 
export default [
  {
    plugins: {
      ourteam: ourteamPlugin,
    },
    rules: {
      "ourteam/use-custom-logger": "error",
      "ourteam/require-body-validation": "error",
      "ourteam/no-console-log": "warn",
    },
  },
  {
    // Disable logger rule in test files
    files: ["**/*.test.ts", "**/*.spec.ts"],
    rules: {
      "ourteam/use-custom-logger": "off",
    },
  },
];

Key Takeaways

  1. Custom ESLint rules automate code review feedback — if you keep leaving the same review comment, write a rule; the editor catches it before the PR is opened
  2. Rules are AST visitors — each rule registers handlers for node types (CallExpression, MemberExpression); ESLint calls your handler when it encounters that node type
  3. Use AST Explorer to understand node structure — paste your target code pattern into astexplorer.net to see the exact node types and properties your rule should match
  4. Auto-fixers increase adoption — a rule with --fix support gets used; a rule that only warns gets ignored; implement fixers for deterministic transformations
  5. Test edge cases, not just the happy path — shadowed variables, different object shapes, and method chains that look similar but are not console all need test coverage
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX