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

1 comment
(Developer Tutorials) - Stop fighting over code style in code reviews. In this practical tutorial, you'll build a custom ESLint plugin from scratch, enforce your team's unique conventions automatically, and ship cleaner code faster.

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

You’ve been there. Every single PR has that one comment: “Hey, can you move this import above the other one?” or “We agreed to use `type` imports for interfaces, remember?”

Code review shouldn’t be a style debate. It should catch real bugs.

Why You Should Hire Vietnamese Developers: A Strategic Advantage for Tech Leaders

Why You Should Hire Vietnamese Developers: A Strategic Advantage for Tech Leaders

TL;DR: Vietnam offers a unique blend of technical talent, cost efficiency, and cultural compatibility for offshore development. Here’s… ...

That’s where a custom ESLint plugin comes in. You automate the boring stuff. You enforce your team’s conventions without nagging. And you do it with code that runs before anyone even opens a PR.

I’ve built a dozen of these plugins over the years—for React teams, for Node.js backends, even for a fintech startup in Ho Chi Minh City that needed strict import ordering for their compliance pipeline. Here’s the exact process I use.

Startup Software Development Case Study: From Idea to 3,000 Users in 6 Weeks

Startup Software Development Case Study: From Idea to 3,000 Users in 6 Weeks

Do you have a startup idea but don’t know where to start? Or have you ever seen a… ...

Why Bother Building a Custom ESLint Plugin?

ESLint ships with hundreds of rules out of the box. Plugins like `eslint-plugin-react` and `@typescript-eslint/eslint-plugin` cover most scenarios.

But they don’t cover *your* team’s specific conventions. Things like:

  • Enforcing a custom import order for your monorepo
  • Banning certain internal APIs that are deprecated but not removed
  • Requiring JSDoc comments on all exported functions
  • Enforcing a specific pattern for error handling

You *could* enforce these in code review. But that’s slow, inconsistent, and wastes your senior devs’ time.

A custom plugin catches these automatically. It runs in CI, in the editor, and in pre-commit hooks. It’s instant feedback, not a comment 24 hours later.

**Real talk:** We built a custom ESLint plugin for a client in Can Tho that enforced their internal module boundary rules. It cut their code review cycle from 2 days to 4 hours. Not because the rules were complex—because the *automation* removed the noise.

Prerequisites

Before we start, you’ll need:

  • Node.js 18+ (LTS is fine)
  • A basic understanding of AST (Abstract Syntax Tree)
  • An existing JavaScript/TypeScript project to test against
  • ESLint 8 or 9 installed in that project

Don’t worry if you’re fuzzy on ASTs. You’ll pick it up as we go.

Project Setup: Scaffolding the Plugin

Let’s create a new plugin from scratch. We’ll call it `eslint-plugin-team-conventions`.

bash
mkdir eslint-plugin-team-conventions
cd eslint-plugin-team-conventions
npm init -y

ESLint plugins follow a naming convention: `eslint-plugin-`. The main entry point exports an object with `rules` and optionally `configs`.

Install the required dependencies:

bash
npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/utils

I prefer using TypeScript for writing plugins. It gives you type safety for the AST nodes and the rule context. But plain JavaScript works too.

Create the directory structure:

bash
mkdir -p src/rules
touch src/index.ts
touch src/rules/no-direct-console.ts

Anatomy of an ESLint Rule

Every ESLint rule is an object with a `meta` property and a `create` function.

Here’s the skeleton:

typescript
// src/rules/no-direct-console.ts
import { AST } from '@typescript-eslint/utils';

export const noDirectConsole = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'Disallow direct console.log calls. Use the team logger instead.',
      recommended: false,
    },
    fixable: 'code',
    schema: [], // no options for this rule
    messages: {
      useLogger: 'Use `logger.info()` instead of `console.log()`.',
    },
  },
  create(context) {
    return {
      CallExpression(node: AST.CallExpression) {
        if (
          node.callee.type === 'MemberExpression' &&
          node.callee.object.type === 'Identifier' &&
          node.callee.object.name === 'console' &&
          node.callee.property.type === 'Identifier' &&
          node.callee.property.name === 'log'
        ) {
          context.report({
            node,
            messageId: 'useLogger',
            fix(fixer) {
              const sourceCode = context.getSourceCode();
              const args = node.arguments.map(arg => sourceCode.getText(arg)).join(', ');
              return fixer.replaceText(node, `logger.info(${args})`);
            },
          });
        }
      },
    };
  },
};

Let’s break this down:

  • `meta.type`: Can be `’problem’`, `’suggestion’`, or `’layout’`. This tells ESLint whether the rule catches errors, suggests improvements, or enforces formatting.
  • `meta.fixable`: If the rule can auto-fix, specify `’code’` or `’whitespace’`.
  • `meta.messages`: A map of message IDs to human-readable messages. Use `messageId` in `context.report()` instead of inline strings. It’s cleaner and enables testing.
  • `create(context)`: Returns an object where keys are AST node types (e.g., `CallExpression`, `ImportDeclaration`) and values are visitor functions.
  • `context.report()`: Reports a lint violation. You pass the node, the messageId, and optionally a fixer.

The fixer is powerful. It transforms the code automatically. In this case, it replaces `console.log(x)` with `logger.info(x)`.

Registering the Rule in the Plugin

Now let’s wire it up in the main entry point:

typescript
// src/index.ts
import { noDirectConsole } from './rules/no-direct-console';

const plugin = {
  rules: {
    'no-direct-console': noDirectConsole,
  },
  configs: {
    recommended: {
      plugins: ['team-conventions'],
      rules: {
        'team-conventions/no-direct-console': 'error',
      },
    },
  },
};

export = plugin;

The `configs` object lets you ship pre-built configurations. Users can extend `plugin:team-conventions/recommended` in their ESLint config and get all your recommended rules with sensible defaults.

Building a More Complex Rule: Enforcing Import Order

Let’s tackle something teams actually fight about: import order.

Here’s a rule that enforces a specific order: built-in modules → third-party → internal modules → relative imports. Each group separated by a blank line.

typescript
// src/rules/import-order.ts
import { AST } from '@typescript-eslint/utils';

interface ImportGroup {
  name: string;
  priority: number;
  pattern: RegExp;
}

const groups: ImportGroup[] = [
  { name: 'builtin', priority: 1, pattern: /^(node:)?(fs|path|os|http|crypto|util|stream)/ },
  { name: 'third-party', priority: 2, pattern: /^[a-z@]/ },
  { name: 'internal', priority: 3, pattern: /^@company\// },
  { name: 'relative', priority: 4, pattern: /^\./ },
];

export const importOrder = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'Enforce a consistent import order: builtin, third-party, internal, relative.',
      recommended: false,
    },
    fixable: 'code',
    schema: [],
    messages: {
      wrongOrder: 'Import "{{import}}" should be in the {{expectedGroup}} group, after a blank line.',
    },
  },
  create(context) {
    const sourceCode = context.getSourceCode();
    return {
      Program(node: AST.Program) {
        const importNodes = node.body.filter(
          (stmt): stmt is AST.ImportDeclaration => stmt.type === 'ImportDeclaration'
        );

        if (importNodes.length === 0) return;

        let lastGroup = 0;
        let lastEndLine = 0;

        for (const importNode of importNodes) {
          const source = importNode.source.value;
          const groupIndex = groups.findIndex(g => g.pattern.test(source));

          if (groupIndex === -1) continue;

          const groupPriority = groups[groupIndex].priority;
          const startLine = importNode.loc?.start.line ?? 0;

          // Check if there's a blank line between groups
          if (groupPriority !== lastGroup && startLine - lastEndLine < 2) {
            context.report({
              node: importNode,
              messageId: 'wrongOrder',
              data: {
                import: source,
                expectedGroup: groups[groupIndex].name,
              },
              fix(fixer) {
                // Insert a blank line before this import
                return fixer.insertTextBefore(importNode, '\n');
              },
            });
          }

          lastGroup = groupPriority;
          lastEndLine = importNode.loc?.end.line ?? 0;
        }
      },
    };
  },
};

This rule walks through all import declarations and checks that:

  1. Imports are grouped by priority order
  2. There's a blank line between groups

The fixer inserts a blank line where needed. It's not perfect—a full auto-sort would reorder imports entirely—but it catches the most common violations.

Pro tip: For a production-quality import sorter, use `eslint-plugin-import` or `@trivago/prettier-plugin-sort-imports`. But this custom rule is perfect for teams that want a *specific* ordering convention that those tools don't support.

Testing Your Custom ESLint Plugin

Never ship a lint rule without tests. ESLint provides a `RuleTester` utility that makes this dead simple.

bash
npm install --save-dev @typescript-eslint/rule-tester
typescript
// tests/no-direct-console.test.ts
import { RuleTester } from '@typescript-eslint/rule-tester';
import { noDirectConsole } from '../src/rules/no-direct-console';

const ruleTester = new RuleTester({
  parser: require.resolve('@typescript-eslint/parser'),
  parserOptions: {
    ecmaVersion: 2022,
    sourceType: 'module',
  },
});

ruleTester.run('no-direct-console', noDirectConsole, {
  valid: [
    'logger.info("hello")',
    'console.warn("this is allowed")',
  ],
  invalid: [
    {
      code: 'console.log("hello")',
      errors: [{ messageId: 'useLogger' }],
      output: 'logger.info("hello")',
    },
    {
      code: 'console.log(a, b)',
      errors: [{ messageId: 'useLogger' }],
      output: 'logger.info(a, b)',
    },
  ],
});

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

Run it:

bash
npx ts-node tests/no-direct-console.test.ts

The `RuleTester` checks that:

  • Valid cases produce zero errors
  • Invalid cases produce the expected errors *and* the auto-fix output matches

This is gold. You can refactor your rule logic with confidence, knowing the tests will catch regressions.

Publishing Your Plugin

Once your rules are tested and working, publish the plugin to npm:

  1. Build the TypeScript:
  2. bash
       npm run build  # assuming you have a build script that compiles src/ to dist/
  1. Set `main` in package.json to point to the compiled output:
  2. json
       {
         "main": "dist/index.js",
         "types": "dist/index.d.ts"
       }
  1. Publish:
  2. bash
       npm publish

Now any project can install and use it:

bash
npm install --save-dev eslint-plugin-team-conventions

And in `.eslintrc.js`:

javascript
module.exports = {
  plugins: ['team-conventions'],
  extends: ['plugin:team-conventions/recommended'],
  rules: {
    'team-conventions/no-direct-console': 'warn',
    'team-conventions/import-order': 'error',
  },
};

Integrating with Your Team's Workflow

A custom ESLint plugin is only useful if your team actually runs it. Here's how I set it up for teams I work with:

  1. Pre-commit hook with `lint-staged`:

Related: affordable software outsourcing — Learn more about how ECOA AI can help your team.

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

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

Related reading: Vietnam Outsourcing: Why 2024 Is the Year to Rethink Your Offshore Strategy

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.