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.
Open-Source Contribution for Beginners: A Practical Roadmap from GitHub to Pull Request
You want to contribute to open-source projects but don't know where to start? This article is a practical… ...
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.
Vietnam Outsourcing in 2025: Why Smart CTOs Are Betting on Southeast Asia’s Rising Tech Hub
TL;DR – What’s This About? Vietnam outsourcing is no longer a "budget backup" — it’s a strategic advantage.… ...
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-
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:
- Imports are grouped by priority order
- 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:
- Build the TypeScript:
bash
npm run build # assuming you have a build script that compiles src/ to dist/
- Set `main` in package.json to point to the compiled output:
json
{
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
- Publish:
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:
- 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