Build a Custom ESLint Plugin: A Step-by-Step Developer Tutorial for Enforcing Team Conventions

1 comment
(Developer Tutorials) - Stop wasting time in code reviews arguing about naming conventions. Here’s how to build a custom ESLint plugin that enforces your team’s rules automatically, with real code examples and a production-ready workflow.

Build a Custom ESLint Plugin: A Step-by-Step Developer Tutorial for Enforcing Team Conventions

Let’s be honest: code reviews shouldn’t be about arguing over naming conventions. Yet here we are, spending 30% of PR feedback on stuff that a machine could catch.

I’ve been there. Recently, I was working with a team in Ho Chi Minh City on a React project where half the devs used `camelCase` for constants and the other half used `UPPER_CASE`. The PR comments were brutal. The solution? A custom ESLint plugin that enforced our team conventions automatically.

Why I Ditched Dependabot for Renovate Bot (And My Open Source Projects Have Never Been Healthier)

Why I Ditched Dependabot for Renovate Bot (And My Open Source Projects Have Never Been Healthier)

Why I Ditched Dependabot for Renovate Bot (And My Open Source Projects Have Never Been Healthier) I’ve been… ...

No more debates. No more “I thought we agreed on this” emails. Just clean code, every time.

Here’s how you build one.

Why Your Multi-Agent System Needs a Shared Memory Layer: Practical Lessons from Production

Why Your Multi-Agent System Needs a Shared Memory Layer: Practical Lessons from Production

Why Your Multi-Agent System Needs a Shared Memory Layer: Practical Lessons from Production We rolled out a 12-agent… ...

Why Build a Custom ESLint Plugin?

ESLint ships with hundreds of rules out of the box. But your team has specific patterns that no generic rule can catch:

  • “All Redux action types must end with `_SUCCESS`, `_FAILURE`, or `_REQUEST`”
  • “Don’t use `any` in TypeScript interfaces for API responses”
  • “Every exported function needs a JSDoc comment”

Sure, you could enforce these in code review. But that’s slow, inconsistent, and drains your senior devs’ energy. A custom plugin runs in 50 milliseconds and never misses a violation.

Actually, there’s a bigger reason: consistency at scale. When your team grows from 5 to 20 developers (like we’ve seen with several clients scaling through ECOA AI’s platform), manual enforcement becomes impossible. A custom plugin scales with zero effort.

Prerequisites

Before we dive in, you’ll need:

  • Node.js 18+ (we’re using ES modules, so modern Node is required)
  • ESLint 8.40+ (ESLint 9 works too, but the API is slightly different)
  • Basic understanding of AST (Abstract Syntax Tree) — don’t worry, I’ll explain the key concepts

Let’s set up the project:

bash
mkdir my-eslint-plugin
cd my-eslint-plugin
npm init -y
npm install eslint @typescript-eslint/parser typescript --save-dev

Step 1: Project Structure

ESLint plugins follow a specific structure. Here’s what we’re building:


my-eslint-plugin/
├── package.json
├── src/
│   ├── rules/
│   │   ├── no-console-log.js
│   │   └── require-jsdoc-on-export.js
│   └── index.js
└── tests/
    ├── no-console-log.test.js
    └── require-jsdoc-on-export.test.js

The `index.js` file is the entry point. It exports a `rules` object where each key is a rule name and each value is a rule definition.

Step 2: Writing Your First Rule

Let’s start with a simple rule: no console.log in production code.

Create `src/rules/no-console-log.js`:

javascript
module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Disallow the use of console.log",
      recommended: true,
    },
    fixable: "code",
    schema: [], // no options
    messages: {
      unexpectedConsoleLog:
        "Unexpected console.log. Use a proper logging library instead.",
    },
  },
  create(context) {
    return {
      CallExpression(node) {
        if (
          node.callee.type === "MemberExpression" &&
          node.callee.object.name === "console" &&
          node.callee.property.name === "log"
        ) {
          context.report({
            node,
            messageId: "unexpectedConsoleLog",
            fix(fixer) {
              // Replace console.log(x) with console.warn(x) as a fix
              return fixer.replaceTextRange(
                [node.callee.property.range[0], node.callee.property.range[1]],
                "warn"
              );
            },
          });
        }
      },
    };
  },
};

Let’s break this down:

  • `meta`: Defines metadata about the rule — type, description, whether it’s fixable, and error messages.
  • `create()`: Returns an object with AST visitor methods. When ESLint traverses the code, it calls these methods whenever it encounters the matching node type.
  • `CallExpression`: This visitor fires for every function call. We check if it’s `console.log()` specifically.
  • `context.report()`: Reports the violation. You can provide a `fix` function to auto-fix the issue.

The AST node structure can be confusing at first. Here’s a quick mental model: every piece of code becomes a tree. `console.log(“hello”)` becomes:


CallExpression
├── callee: MemberExpression
│   ├── object: Identifier (name: "console")
│   └── property: Identifier (name: "log")
└── arguments: [Literal (value: "hello")]

You can explore any code’s AST using AST Explorer — it’s invaluable for debugging.

Step 3: A More Complex Rule — JSDoc on Exports

Here’s where things get interesting. Let’s enforce that every exported function must have a JSDoc comment.

Create `src/rules/require-jsdoc-on-export.js`:

javascript
module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Require JSDoc comments on exported functions",
      recommended: false,
    },
    fixable: null,
    schema: [],
    messages: {
      missingJSDoc:
        "Exported function '{{name}}' is missing a JSDoc comment.",
    },
  },
  create(context) {
    const sourceCode = context.getSourceCode();

    return {
      ExportNamedDeclaration(node) {
        if (!node.declaration) return;

        if (
          node.declaration.type === "FunctionDeclaration" ||
          (node.declaration.type === "VariableDeclaration" &&
            node.declaration.declarations[0]?.init?.type === "ArrowFunctionExpression")
        ) {
          const funcName =
            node.declaration.id?.name ||
            node.declaration.declarations[0]?.id?.name;

          const comments = sourceCode.getCommentsBefore(node);
          const hasJSDoc = comments.some(
            (comment) => comment.type === "Block" && comment.value.startsWith("*")
          );

          if (!hasJSDoc) {
            context.report({
              node,
              messageId: "missingJSDoc",
              data: { name: funcName || "anonymous" },
            });
          }
        }
      },
    };
  },
};

This rule:

  1. Listens for `ExportNamedDeclaration` nodes
  2. Checks if the export is a function or an arrow function
  3. Uses `sourceCode.getCommentsBefore()` to look for JSDoc comments
  4. Reports if no JSDoc is found

The `data` property in `context.report()` lets you interpolate values into your message template.

Step 4: Registering Rules in the Plugin

Now wire everything together in `src/index.js`:

javascript
const noConsoleLog = require("./rules/no-console-log");
const requireJSDocOnExport = require("./rules/require-jsdoc-on-export");

module.exports = {
  rules: {
    "no-console-log": noConsoleLog,
    "require-jsdoc-on-export": requireJSDocOnExport,
  },
  configs: {
    recommended: {
      plugins: ["my-eslint-plugin"],
      rules: {
        "my-eslint-plugin/no-console-log": "error",
        "my-eslint-plugin/require-jsdoc-on-export": "warn",
      },
    },
  },
};

The `configs` object lets you define preset configurations. Users can extend `plugin:my-eslint-plugin/recommended` to get all recommended rules at once.

Step 5: Testing Your Plugin

Testing is non-negotiable. ESLint provides a `RuleTester` utility that makes it easy:

javascript
// tests/no-console-log.test.js
const { RuleTester } = require("eslint");
const rule = require("../src/rules/no-console-log");

const ruleTester = new RuleTester();

ruleTester.run("no-console-log", rule, {
  valid: [
    { code: 'console.warn("test")' },
    { code: 'logger.info("test")' },
    { code: 'const x = 5;' },
  ],
  invalid: [
    {
      code: 'console.log("test")',
      errors: [{ messageId: "unexpectedConsoleLog" }],
      output: 'console.warn("test")', // after fix
    },
  ],
});

console.log("All tests passed!");

Run it:

bash
node tests/no-console-log.test.js

You should see “All tests passed!” with no errors. If you get failures, the error messages from `RuleTester` are extremely descriptive — they’ll tell you exactly which test case failed and why.

Step 6: Publishing and Using Your Plugin

To use your plugin locally, add it to your project:

json
// package.json in your main project
{
  "devDependencies": {
    "eslint-plugin-my-custom-rules": "file:../my-eslint-plugin"
  }
}

Then in your `.eslintrc.js`:

javascript
module.exports = {
  plugins: ["my-custom-rules"],
  rules: {
    "my-custom-rules/no-console-log": "error",
    "my-custom-rules/require-jsdoc-on-export": "warn",
  },
};

For team-wide usage, publish to npm:

bash
npm publish --access public

Then your team just adds `eslint-plugin-your-plugin-name` to their `devDependencies` and extends the config.

Real-World Tips from Production

After shipping custom ESLint plugins for several teams (including teams we’ve helped scale through ECOA AI’s platform), here’s what I’ve learned:

Tip 1: Start small. Pick one convention that causes the most PR friction. Automate that first. Our team in Can Tho started with just a “no `any`” rule and expanded gradually.

Tip 2: Use `fixable: “code”` when possible. Auto-fixable rules get adopted 3x faster. Nobody wants to manually add JSDoc comments.

Tip 3: Test edge cases. Arrow functions, default exports, and anonymous functions all behave differently. Write tests for each.

Tip 4: Performance matters. ESLint runs on every file. If your rule does heavy computation, use caching or bail early. A rule that checks `CallExpression` should return immediately if the callee isn’t a member expression.

Tip 5: Document your rules. When a new dev sees “my-eslint-plugin/no-console-log” in the config, they need to know what it does. Add a README with examples.

When NOT to Build a Custom Plugin

Honestly, not everything needs a custom rule. Before you build one, ask:

  • Does an existing ESLint plugin already cover this? (Check `eslint-plugin-unicorn`, `eslint-plugin-react`, etc.)
  • Is this a style preference that Prettier handles?
  • Will this rule create more noise than value?

If you’re writing a rule that fires on every file and has a 40% false positive rate, your team will hate you. Trust me.

The Bottom Line

Building a custom ESLint plugin takes about an hour. That hour saves your team dozens of hours in code review debates over the next year. It’s one of the highest-ROI investments you can make in your codebase’s health.

Plus, once you understand the AST visitor pattern, you can build rules for anything. TypeScript-specific checks? Done. Framework conventions? Easy. Team-specific architectural patterns? You bet.

Start with one rule. Test it thoroughly. Ship it. Then watch your code reviews transform from arguments about style to discussions about architecture.

That’s where the real value lives.

Frequently Asked Questions

How do I debug an ESLint plugin while developing it?

Use `console.log` in your `create()` function to inspect AST nodes. Better yet, use the `context.getSourceCode()` method to get the full source text and `sourceCode.getText(node)` to see what a node represents. For visual debugging, paste your code into AST Explorer with the ESLint parser selected.

Can I use TypeScript to write my ESLint plugin?

Yes, but you’ll need to compile it to JavaScript before publishing. Use `tsc` to compile your `src/` directory, then point your `main` field in `package.json` to the compiled output. Many popular plugins like `@typescript-eslint/eslint-plugin` are written in TypeScript internally.

How do I handle rule options passed from the ESLint config?

Access them via `context.options` in your `create()` function. Validate them using the `schema` array in `meta`. For example, if a user sets `”my-rule”: [“error”, { “allowList”: [“warn”] }]`, you’d access `context.options[0].allowList` in your rule logic.

Why isn’t my fix function being applied automatically?

ESLint only applies fixes automatically when you run `eslint –fix`. Make sure your fix function returns a valid `fixer` object. Common mistakes: returning `null` instead of a fix object, or trying to fix nodes that don’t have stable ranges. Always test your fix output with the `output` property in `RuleTester` test cases.

Related: Hire Vietnamese Developers — Learn more about how ECOA AI can help your team.

Related: hire software developers in Vietnam — Learn more about how ECOA AI can help your team.

Related: Vietnamese software developers — Learn more about how ECOA AI can help your team.

Related: Hire Elite Vietnamese Developers — Learn more about how ECOA AI can help your team.

Related: Vietnam development team — Learn more about how ECOA AI can help your team.

Related reading: Outsourcing Software in 2025: The Real Playbook for CTOs and Startup Founders

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.