Custom Rules
Custom rules allow you to enforce code standards specific to your project. This guide shows you how to create, test, and distribute custom rules.
Rule Structure
Every rule is a JavaScript module that exports an object with two main properties:
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Disallow unused variables",
recommended: true,
url: "https://example.com/rules/my-rule"
},
fixable: "code",
schema: [],
messages: {
unused: "'{{name}}' is defined but never used."
}
},
create(context) {
return {
// visitor methods
};
}
};
The meta object contains metadata about your rule:
Rule type: "problem", "suggestion", or "layout"
"problem": Code that will cause errors or confusing behavior
"suggestion": Code that could be improved but won’t cause errors
"layout": Whitespace, semicolons, and code formatting
Documentation metadataShort description of what the rule does
Whether the rule is recommended
URL to full documentation
Either "code" or "whitespace" if the rule supports automatic fixesThe fixable property is mandatory for fixable rules. Omit it if your rule doesn’t provide fixes.
Set to true if the rule provides suggestionsThe hasSuggestions property is mandatory for rules that provide suggestions.
JSON Schema defining the rule’s options. Mandatory when the rule has options.
Object mapping message IDs to message templatesmessages: {
avoidName: "Avoid using variables named '{{name}}'",
unexpected: "Unexpected identifier"
}
The Create Function
The create() function returns an object with visitor methods that ESLint calls while traversing the AST:
create(context) {
return {
// Visit Identifier nodes going down the tree
Identifier(node) {
// check the node
},
// Visit FunctionExpression nodes going up the tree
"FunctionExpression:exit"(node) {
// check after visiting children
},
// Code path analysis
onCodePathStart(codePath, node) {
// track code paths
}
};
}
Real Example: no-console
Here’s a simplified version of ESLint’s no-console rule:
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Disallow the use of console",
recommended: false,
url: "https://eslint.org/docs/rules/no-console"
},
schema: [
{
type: "object",
properties: {
allow: {
type: "array",
items: { type: "string" },
minItems: 1,
uniqueItems: true
}
},
additionalProperties: false
}
],
messages: {
unexpected: "Unexpected console statement."
}
},
create(context) {
const config = context.options[0] || {};
const allowed = config.allow || [];
return {
MemberExpression(node) {
if (node.object.name === "console") {
const methodName = node.property.name;
if (!allowed.includes(methodName)) {
context.report({
node,
messageId: "unexpected"
});
}
}
}
};
}
};
The Context Object
The context parameter provides essential information and methods:
The filename being linted
Object to interact with the source code
Configured options for the rule (excludes severity)
Shared settings from configuration
Language configuration (sourceType, ecmaVersion, parser, etc.)
Reports a problem in the code
Reporting Problems
Use context.report() to report violations:
context.report({
node, // The AST node
messageId: "avoidName", // Message ID from meta.messages
data: { // Data for message template
name: node.name
},
fix(fixer) { // Optional autofix
return fixer.insertTextAfter(node, ";");
}
});
Using Message IDs
Message IDs are the recommended way to define messages:
module.exports = {
meta: {
messages: {
avoidFoo: "Avoid using variables named '{{name}}'"
}
},
create(context) {
return {
Identifier(node) {
if (node.name === "foo") {
context.report({
node,
messageId: "avoidFoo",
data: { name: "foo" }
});
}
}
};
}
};
Applying Fixes
Provide automatic fixes using the fix function:
context.report({
node,
messageId: "missingSemi",
fix(fixer) {
return fixer.insertTextAfter(node, ";");
}
});
Fixer Methods
fixer.insertTextAfter(nodeOrToken, text)
Insert text after a node or token
fixer.insertTextBefore(nodeOrToken, text)
Insert text before a node or token
fixer.remove(nodeOrToken)
Remove a node or token
fixer.replaceText(nodeOrToken, text)
Replace the text of a node or token
fixer.insertTextAfterRange(range, text)
Insert text after a range
fixer.insertTextBeforeRange(range, text)
Insert text before a range
fixer.replaceTextRange(range, text)
Replace text in a range
Fix Best Practices
Follow these guidelines when creating fixes:
- Don’t change runtime behavior - Fixes should only improve code style
- Make fixes small - Large fixes can conflict with other rules
- One fix per message - Return a single fix or array of fixes
- Don’t check for style conflicts - ESLint will re-run rules after fixes
Providing Suggestions
Suggestions are alternatives to automatic fixes that require user approval:
module.exports = {
meta: {
hasSuggestions: true,
messages: {
unnecessaryEscape: "Unnecessary escape character: \\{{char}}.",
removeEscape: "Remove the `\\`.",
escapeBackslash: "Replace `\\` with `\\\\`."
}
},
create(context) {
return {
Literal(node) {
// ... detect unnecessary escape
context.report({
node,
messageId: "unnecessaryEscape",
data: { char },
suggest: [
{
messageId: "removeEscape",
fix(fixer) {
return fixer.removeRange(range);
}
},
{
messageId: "escapeBackslash",
fix(fixer) {
return fixer.insertTextBeforeRange(range, "\\");
}
}
]
});
}
};
}
};
Accessing Source Code
The SourceCode object provides methods to work with the code:
const sourceCode = context.sourceCode;
// Get the source text
const text = sourceCode.getText(node);
// Get tokens
const firstToken = sourceCode.getFirstToken(node);
const lastToken = sourceCode.getLastToken(node);
// Get comments
const comments = sourceCode.getCommentsBefore(node);
// Check for whitespace
if (sourceCode.isSpaceBetween(token1, token2)) {
// ...
}
Common SourceCode Methods
Returns the source code for a nodeconst code = sourceCode.getText(node);
// With padding:
const codeWithContext = sourceCode.getText(node, 2, 2);
Returns the first token of a nodeconst token = sourceCode.getFirstToken(node);
// Skip tokens:
const token = sourceCode.getFirstToken(node, { skip: 1 });
getTokensBefore(node, count)
Returns tokens before a nodeconst tokens = sourceCode.getTokensBefore(node, 2);
Working with Options
Define a schema to validate rule options:
module.exports = {
meta: {
schema: [
{
enum: ["always", "never"]
},
{
type: "object",
properties: {
exceptRange: { type: "boolean" }
},
additionalProperties: false
}
]
},
create(context) {
const mode = context.options[0] || "always";
const config = context.options[1] || {};
// Use options...
}
};
Accessing Variable Scopes
Track variables and their usage:
create(context) {
return {
Identifier(node) {
const scope = context.sourceCode.getScope(node);
const variable = scope.variables.find(v => v.name === node.name);
if (variable) {
// Check references
const references = variable.references;
const isRead = references.some(ref => ref.isRead());
}
}
};
}
Creating Your First Rule
Define the Rule
Create a new file for your rule:module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Require let or const instead of var"
},
fixable: "code",
schema: [],
messages: {
unexpected: "Unexpected var, use let or const instead."
}
},
create(context) {
return {
VariableDeclaration(node) {
if (node.kind === "var") {
context.report({
node,
messageId: "unexpected",
fix(fixer) {
const sourceCode = context.sourceCode;
const firstToken = sourceCode.getFirstToken(node);
return fixer.replaceText(firstToken, "let");
}
});
}
}
};
}
};
Test Your Rule
Create tests using RuleTester:const { RuleTester } = require("eslint");
const rule = require("../../lib/rules/no-var");
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: 2015 }
});
ruleTester.run("no-var", rule, {
valid: [
"let x = 1;",
"const y = 2;"
],
invalid: [
{
code: "var x = 1;",
output: "let x = 1;",
errors: [{ messageId: "unexpected" }]
}
]
});
Use the Rule
Add to your ESLint configuration:import noVar from "./lib/rules/no-var.js";
export default [
{
plugins: {
custom: { rules: { "no-var": noVar } }
},
rules: {
"custom/no-var": "error"
}
}
];
Testing Rules
Use RuleTester to validate your rule:
const { RuleTester } = require("eslint");
const rule = require("../rules/my-rule");
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "module"
}
});
ruleTester.run("my-rule", rule, {
valid: [
"const x = 1;",
{ code: "let y = 2;", options: ["allow-let"] }
],
invalid: [
{
code: "var x = 1;",
errors: [{ messageId: "noVar" }],
output: "let x = 1;"
}
]
});
Runtime Rules
Test rules locally without publishing:
Add your rule file
cp my-rule.js eslint_rules/
Configure ESLint
export default [
{
rules: {
"my-rule": "error"
}
}
];
Run with --rulesdir
eslint --rulesdir eslint_rules src/
Rules run on every file. Follow these practices:
- Target specific nodes: Only visit necessary node types
- Avoid expensive operations: No file I/O or network requests
- Cache results: Store computed values
- Use early returns: Exit visitor methods quickly when possible
Profile Your Rule
TIMING=1 eslint --rule 'my-rule: error' lib
Real-World Examples
Learn from ESLint’s built-in rules:
no-unused-vars
Complex scope analysis and variable tracking
semi
Automatic fixing and option handling
no-shadow
Scope traversal and variable shadowing
array-callback-return
Code path analysis
Next Steps
Create a Plugin
Package your rule for distribution
Learn About Selectors
Use advanced AST selectors
Scope Manager
Deep dive into scope analysis
Code Path Analysis
Analyze execution paths