Enforcing Project-Specific Patterns with Custom ESLint Rules
Learn how to build and implement custom ESLint rules using the AST to enforce project-specific architectural patterns and automate code migrations.
17 Apr 2026, 14:02 UTC

The Problem: Generic Linting vs. Domain Constraints
Standard ESLint configurations catch syntax errors and general bad practices, but they cannot enforce project-specific architectural constraints. For example, if your team decides that all API calls must use a specific wrapper function rather than the native fetch API to ensure consistent logging and error handling, a standard rule cannot flag the use of fetch.
The solution is to create a custom rule that leverages the Abstract Syntax Tree (AST). By targeting specific node types, you can programmatically forbid certain patterns and provide automated fixes to migrate the codebase to the approved standard.
Prerequisites
- Node.js environment (LTS recommended).
- An existing ESLint installation (v8.0.0 or later).
- Basic familiarity with the ESTree specification, which defines how JavaScript is represented as a tree of objects.
Building a Custom Rule
ESLint rules operate as visitors. As the parser (Espree) traverses the code, it triggers callbacks when it encounters specific node types. To enforce the use of a wrapper over fetch, you must target CallExpression nodes.
1. Define the Rule Logic
Create a file for your rule (e.g., rules/no-native-fetch.js). The rule must export an object with a meta section for documentation and a create function for the logic.
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Enforce use of project-specific apiWrapper() instead of native fetch()",
category: "Possible Errors",
},
fixable: "code",
schema: [],
},
create(context) {
return {
CallExpression(node) {
if (node.callee.type === "Identifier" && node.callee.name === "fetch") {
context.report({
node,
message: "Use apiWrapper() instead of the native fetch() API.",
fix(fixer) {
return fixer.replaceText(node.callee, "apiWrapper");
},
});
}
},
};
},
};
2. Packaging as a Plugin
ESLint cannot load standalone rule files directly from a config. You must wrap the rule in a plugin object. Create a file named eslint-plugin-project-rules.js:
module.exports = {
rules: {
"no-native-fetch": require("./rules/no-native-fetch"),
},
};
3. Integrating with Configuration
To use the local plugin without publishing it to npm, you can use the plugins array in your .eslintrc or eslint.config.js. If using the legacy .eslintrc format, you may need to link the plugin locally via npm link or use a plugin loader.
// .eslintrc.json
{
"plugins": ["project-rules"],
"rules": {
"project-rules/no-native-fetch": "error"
}
}
Verification and Testing
Custom rules can accidentally flag valid code or cause infinite loops during fixing. Use the RuleTester class provided by ESLint to validate the logic before deploying it to the team.
Diagnostic Test Suite
Run this test script using Node.js to ensure the rule behaves as expected:
const { RuleTester } = require("eslint");
const rule = require("./rules/no-native-fetch");
const ruleTester = new RuleTester();
ruleTester.run("no-native-fetch", rule, {
valid: [
"apiWrapper('/api/data')",
"myCustomFetch('/api/data')",
],
invalid: [
{
code: "fetch('/api/data')",
errors: [{ message: "Use apiWrapper() instead of the native fetch() API." }],
output: "apiWrapper('/api/data')",
},
],
});
Manual Verification
- Run the lint command on a target file:
npx eslint path/to/file.js. - Verify that the
fetchcall is flagged as an error. - Run the fix command:
npx eslint path/to/file.js --fix. - Check the file to ensure
fetchwas replaced byapiWrapper.
Performance and Safety Limitations
- AST Traversal Cost: Avoid using expensive regular expressions inside the visitor functions. Since these functions run for every node of a specific type, inefficient logic will noticeably slow down IDE responsiveness and CI pipelines.
- Fixer Risks: The
fixer.replaceTextmethod is powerful but blind to scope. If a variable is namedfetchbut is not the global API (e.g., a local variable in a loop), the fixer will still replace it. To prevent this, verify the scope of the identifier usingcontext.getScope(). - Rollback: Because
eslint --fixmodifies files on disk, the only recovery method is using version control (Git). Always commit your current state before running a new custom fixer across a large codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.