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.

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.
// 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
# Project structure
mkdir eslint-plugin-ourteam && cd eslint-plugin-ourteam
npm init -y
npm install -D eslint @types/eslint typescript vitest{
"name": "eslint-plugin-ourteam",
"version": "1.0.0",
"main": "dist/index.js",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest"
}
}// 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."
// 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."
// 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).
// 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")',
},
],
});
});
});// ❌ 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
# 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// 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
- 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
- Rules are AST visitors — each rule registers handlers for node types (CallExpression, MemberExpression); ESLint calls your handler when it encounters that node type
- 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
- Auto-fixers increase adoption — a rule with
--fixsupport gets used; a rule that only warns gets ignored; implement fixers for deterministic transformations - 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


