Skip to main content

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

The meta object contains metadata about your rule:
type
string
required
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
docs
object
Documentation metadata
docs.description
string
required
Short description of what the rule does
Whether the rule is recommended
docs.url
string
URL to full documentation
fixable
string
Either "code" or "whitespace" if the rule supports automatic fixes
The fixable property is mandatory for fixable rules. Omit it if your rule doesn’t provide fixes.
hasSuggestions
boolean
Set to true if the rule provides suggestions
The hasSuggestions property is mandatory for rules that provide suggestions.
schema
array | object | false
JSON Schema defining the rule’s options. Mandatory when the rule has options.
messages
object
Object mapping message IDs to message templates
messages: {
  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
    }
  };
}
Visit ESLint Code Explorer to see the AST structure for any JavaScript code.

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:
context.id
string
The rule ID
context.filename
string
The filename being linted
context.sourceCode
SourceCode
Object to interact with the source code
context.options
array
Configured options for the rule (excludes severity)
context.settings
object
Shared settings from configuration
context.languageOptions
object
Language configuration (sourceType, ecmaVersion, parser, etc.)
context.report()
function
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)
function
Insert text after a node or token
fixer.insertTextBefore(nodeOrToken, text)
function
Insert text before a node or token
fixer.remove(nodeOrToken)
function
Remove a node or token
fixer.replaceText(nodeOrToken, text)
function
Replace the text of a node or token
fixer.insertTextAfterRange(range, text)
function
Insert text after a range
fixer.insertTextBeforeRange(range, text)
function
Insert text before a range
fixer.removeRange(range)
function
Remove text in a range
fixer.replaceTextRange(range, text)
function
Replace text in a range

Fix Best Practices

Follow these guidelines when creating fixes:
  1. Don’t change runtime behavior - Fixes should only improve code style
  2. Make fixes small - Large fixes can conflict with other rules
  3. One fix per message - Return a single fix or array of fixes
  4. 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 node
const code = sourceCode.getText(node);
// With padding:
const codeWithContext = sourceCode.getText(node, 2, 2);
Returns the first token of a node
const token = sourceCode.getFirstToken(node);
// Skip tokens:
const token = sourceCode.getFirstToken(node, { skip: 1 });
Returns tokens before a node
const tokens = sourceCode.getTokensBefore(node, 2);
Returns comments before a node
const comments = sourceCode.getCommentsBefore(node);

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

1

Define the Rule

Create a new file for your rule:
lib/rules/no-var.js
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");
            }
          });
        }
      }
    };
  }
};
2

Test Your Rule

Create tests using RuleTester:
tests/rules/no-var.js
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" }]
    }
  ]
});
3

Use the Rule

Add to your ESLint configuration:
eslint.config.js
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:
1

Create a rules directory

mkdir eslint_rules
2

Add your rule file

cp my-rule.js eslint_rules/
3

Configure ESLint

eslint.config.js
export default [
  {
    rules: {
      "my-rule": "error"
    }
  }
];
4

Run with --rulesdir

eslint --rulesdir eslint_rules src/

Performance Tips

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