| | |

ESLint 26 🛡️ Writing a Custom Rule — context.report and the AST

⏱️ Estimated read: 12 min | |

A custom ESLint rule is a module that exports an object with two properties: meta and create. The meta property describes the rule—its type, its documentation, its options schema, and its message templates. The create method receives the rule context and returns a visitor object whose keys are AST node types or selectors, and whose values are functions that run when the traversal reaches a matching node. The context.report method is the mechanism that publishes a problem, and it accepts an object with the node that caused the problem, the message or message ID, and optional data for interpolation and a fix function .

The AST is the structure that the rule traverses. ESLint parses the source into an Abstract Syntax Tree, and each node in the tree represents a syntactic element—a VariableDeclaration, a CallExpression, an Identifier, a Literal. The visitor object’s keys are the node types or the CSS-like selectors that match those nodes . The rule’s logic runs when the traversal reaches the matching node, and the context.report call is the output. The two pieces are connected: the visitor finds the node, and the report publishes the problem.

Key point: A custom rule exports meta and create. The create method returns a visitor object whose keys are AST node types or selectors. The context.report method accepts { node, messageId, data, fix } and publishes the problem at the node’s location. The messageId is defined in meta.messages, and it is the preferred form over the raw message string.


Why the visitor and the report are separate

The traversal problem. ESLint does not run a rule on the whole file at once. It traverses the AST depth-first, and it calls the visitor function for each node whose type matches a key in the visitor object. The traversal is the mechanism that gives the rule the node it needs without the rule having to search the tree itself. The visitor object is the rule’s subscription to the node types it cares about .

The report problem. The visitor function does not throw an error or return a value. It calls context.report with the node that caused the problem. The report method extracts the line and column from the node’s loc property, applies the message template with the data interpolation, and adds the problem to the list that ESLint returns. The separation is the mechanism that allows a rule to report multiple problems in a single file and to attach a fix to each one .

The messageId problem. The older form of context.report accepted a raw message string. The newer form accepts a messageId that refers to an entry in meta.messages. The messageId form is the preferred one because it keeps the message text in the meta object, where it can be documented and localized, and because it allows the test to assert on the ID rather than the full string . The raw message form still works, but the messageId form is the one that the rule authoring guide recommends.

The trade-off. The visitor object is a plain object, and the keys are strings. A typo in a node type name produces a visitor that never runs, and the rule silently does nothing. The messageId form has the same problem: a typo in the ID produces a report that throws at runtime, because the ID is not in meta.messages. The two failure modes are different, and the test file is the mechanism that catches both. The RuleTester runs the rule against the valid and invalid cases, and a visitor that never runs fails the invalid case, while a missing messageId fails the test with a clear error .


a. The rule structure and the visitor object

The rule module exports an object with meta and create. The meta object contains the type, the docs, the schema, and the messages. The create method receives the context and returns the visitor object.

// rules/no-console-log.js — the rule structure
export default {
  meta: {
    // The type is "problem", "suggestion", or "layout".
    type: "problem",
    docs: {
      description: "Disallow the use of console.log",
      recommended: true,
    },
    // The schema validates the rule options.
    schema: [],
    // The messages are the templates that messageId refers to.
    messages: {
      unexpected: "Unexpected console.log statement.",
    },
  },
  create(context) {
    return {
      // The key is the AST node type.
      CallExpression(node) {
        // The check: is this a console.log call?
        if (
          node.callee.type === "MemberExpression" &&
          node.callee.object.name === "console" &&
          node.callee.property.name === "log"
        ) {
          // The report: publish the problem at the node's location.
          context.report({
            node,
            messageId: "unexpected",
          });
        }
      },
    };
  },
};

The visitor key is CallExpression, which means the function runs for every function call in the file. The check inside the function narrows the calls to the ones that are console.log. The context.report call publishes the problem, and the messageId: "unexpected" refers to the template in meta.messages .


b. The context.report object and the fix function

The context.report method accepts an object with the node, the messageId or message, the optional data, and the optional fix. The data object is the interpolation values for the message template, and the fix function is the mechanism that applies an automatic correction.

// rules/no-console-log.js — the report with data and fix
export default {
  meta: {
    type: "problem",
    fixable: "code", // The fix is enabled by this property.
    messages: {
      unexpected: "Unexpected console.{{method}} statement.",
    },
    schema: [],
  },
  create(context) {
    return {
      CallExpression(node) {
        if (
          node.callee.type === "MemberExpression" &&
          node.callee.object.name === "console"
        ) {
          const method = node.callee.property.name;
          context.report({
            node,
            messageId: "unexpected",
            // The data interpolates the {{method}} placeholder.
            data: { method },
            // The fix function returns the replacement.
            fix(fixer) {
              // The fixer removes the statement.
              return fixer.remove(node);
            },
          });
        }
      },
    };
  },
};

The meta.fixable: "code" property is required for the fix to be applied. Without it, the fix function is ignored. The fix function receives a fixer object and returns a fix or an array of fixes. The fixer.remove(node) is the fix that deletes the node from the source .

The fixer object has the methods that the fix needs: remove, replaceText, insertTextBefore, insertTextAfter, replaceTextRange. The fix is a range replacement, and the range is the node’s range. The fix function must return a fix that does not overlap with another fix, or ESLint applies only one of them.


Complete Example Session

// ============================================
// PART 1: THE RULE MODULE
// ============================================

// rules/no-console-log.js
export default {
  meta: {
    type: "problem",
    docs: {
      description: "Disallow the use of console.log",
      recommended: true,
    },
    fixable: "code",
    schema: [],
    messages: {
      unexpected: "Unexpected console.{{method}} statement.",
    },
  },
  create(context) {
    return {
      CallExpression(node) {
        if (
          node.callee.type === "MemberExpression" &&
          node.callee.object.name === "console"
        ) {
          const method = node.callee.property.name;
          context.report({
            node,
            messageId: "unexpected",
            data: { method },
            fix(fixer) {
              return fixer.remove(node);
            },
          });
        }
      },
    };
  },
};

// ============================================
// PART 2: THE RULE TEST
// ============================================

// tests/no-console-log.test.js
import { RuleTester } from "eslint";
import rule from "../rules/no-console-log.js";

const ruleTester = new RuleTester({
  languageOptions: { ecmaVersion: 2022, sourceType: "module" },
});

ruleTester.run("no-console-log", rule, {
  valid: [
    { code: "console.error('error');" },
    { code: "logger.log('info');" },
  ],
  invalid: [
    {
      code: "console.log('hello');",
      errors: [{ messageId: "unexpected", data: { method: "log" } }],
      output: "", // The fix removes the statement.
    },
  ],
});

// ============================================
// PART 3: THE SELECTOR FORM
// ============================================

// The visitor key can be a selector instead of a node type.
// The selector matches the console.log call directly.
export default {
  meta: { /* ... */ },
  create(context) {
    return {
      // The selector matches the MemberExpression callee.
      "CallExpression[callee.object.name='console']"(node) {
        const method = node.callee.property.name;
        context.report({ node, messageId: "unexpected", data: { method } });
      },
    };
  },
};

// ============================================
// PART 4: THE RULE REGISTRATION
// ============================================

// eslint.config.js
import noConsoleLog from "./rules/no-console-log.js";

export default [
  {
    plugins: {
      custom: {
        rules: {
          "no-console-log": noConsoleLog,
        },
      },
    },
    rules: {
      "custom/no-console-log": "error",
    },
  },
];

// ============================================
// PART 5: THE META TYPE
// ============================================

// The type is one of:
// - "problem": the rule identifies a problem.
// - "suggestion": the rule suggests an improvement.
// - "layout": the rule enforces a formatting convention.
// The type is used by the documentation and the CLI.

// ============================================
// PART 6: THE SCHEMA
// ============================================

// The schema validates the rule options.
export default {
  meta: {
    schema: [
      {
        type: "object",
        properties: {
          allow: { type: "array", items: { type: "string" } },
        },
        additionalProperties: false,
      },
    ],
  },
  create(context) {
    const options = context.options[0] || {};
    const allow = options.allow || [];
    // ...
  },
};

// ============================================
// PART 7: THE FIXER METHODS
// ============================================

// fixer.remove(node) — remove the node.
// fixer.replaceText(node, "replacement") — replace the node.
// fixer.insertTextBefore(node, "text") — insert before the node.
// fixer.insertTextAfter(node, "text") — insert after the node.
// fixer.replaceTextRange([start, end], "text") — replace the range.

// ============================================
// PART 8: THE MESSAGE ID VS MESSAGE
// ============================================

// The messageId form:
context.report({ node, messageId: "unexpected", data: { method: "log" } });

// The message form (older):
context.report({ node, message: "Unexpected console.log statement." });

// The messageId form is preferred.
// The message is defined in meta.messages.

// ============================================
// PART 9: THE SELECTOR SYNTAX
// ============================================

// The selector syntax is CSS-like.
// "CallExpression" — every call.
// "CallExpression[callee.object.name='console']" — console calls.
// "FunctionDeclaration[params.length>3]" — functions with 3+ params.
// "IfStatement > BlockStatement" — if with a block.

// ============================================
// PART 10: THE CHECKLIST
// ============================================

// [ ] The rule exports meta and create.
// [ ] The meta.type is "problem", "suggestion", or "layout".
// [ ] The meta.messages contains the messageId templates.
// [ ] The meta.schema validates the options.
// [ ] The meta.fixable is set if the rule has a fix.
// [ ] The create method returns a visitor object.
// [ ] The visitor keys are node types or selectors.
// [ ] The context.report uses messageId, not message.
// [ ] The fix function returns a fixer result.
// [ ] The rule is tested with RuleTester.

The ten parts covered the rule module, the test, the selector form, the registration, the meta type, the schema, the fixer methods, the messageId versus message, the selector syntax, and the checklist. The through-line is that the visitor finds the node, and the report publishes the problem.


Quick Reference

The rule structure

PropertyPurpose
meta.type"problem", "suggestion", or "layout"
meta.docsThe description and the recommended flag
meta.schemaThe JSON schema for the options
meta.messagesThe message templates
meta.fixable"code" if the rule has a fix
create(context)Returns the visitor object

The context.report object

PropertyPurpose
nodeThe AST node that caused the problem
messageIdThe key in meta.messages
dataThe interpolation values
fixThe function that returns the fix

The fixer methods

MethodPurpose
fixer.remove(node)Remove the node
fixer.replaceText(node, text)Replace the node
fixer.insertTextBefore(node, text)Insert before the node
fixer.insertTextAfter(node, text)Insert after the node
fixer.replaceTextRange(range, text)Replace the range

The selector examples

SelectorMatches
CallExpressionEvery call
CallExpression[callee.object.name='console']Console calls
FunctionDeclaration[params.length>3]Functions with 3+ params
IfStatement > BlockStatementIf with a block

Best Practices

✅ Do This:

// Use the messageId form
context.report({ node, messageId: "unexpected", data: { method } }); // ✅
// Set the fixable property
meta: { fixable: "code" }                                           // ✅
// Use a selector to narrow the visitor
"CallExpression[callee.object.name='console']"(node) { /* ... */ }   // ✅

❌ Don’t Do This:

// Use the raw message string
context.report({ node, message: "Unexpected." });                   // ❌ (messageId preferred)
// Forget the fixable property
meta: {}, fix(fixer) { /* ... */ } // the fix is ignored            // ❌

Common Pitfalls

PitfallWhy It HappensFix
The rule does nothingThe visitor key is a typoCheck the AST node type
The fix is not appliedmeta.fixable is missingAdd fixable: "code"
The report throwsThe messageId is not in meta.messagesAdd the template to messages
The selector does not matchThe selector syntax is wrongCheck the selector against the AST
The fix overlaps another fixTwo fixes target the same rangeReturn one fix or split the rule

Real-World Examples

1. The visitor

CallExpression(node) { /* ... */ }

2. The report

context.report({ node, messageId: "unexpected" });

3. The data interpolation

context.report({ node, messageId: "unexpected", data: { method: "log" } });

4. The fix

context.report({ node, messageId: "unexpected", fix(fixer) { return fixer.remove(node); } });

5. The selector

"CallExpression[callee.object.name='console']"(node) { /* ... */ }

6. The schema

meta: { schema: [{ type: "object" }] }

7. The message

meta: { messages: { unexpected: "Unexpected console.{{method}} statement." } }

8. The test

ruleTester.run("no-console-log", rule, { valid: [], invalid: [] });

9. The registration

plugins: { custom: { rules: { "no-console-log": rule } } }

10. The fixable

meta: { fixable: "code" }

Visual

The Rule Pipeline

┌──────────────────────────────────────────────────────────────┐
│  SOURCE → AST → VISITOR → REPORT → PROBLEM                    │
│                                                              │
│  Source code                                                 │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ ESLint parses the   │                                    │
│  │ source into an AST  │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ The traversal calls │                                    │
│  │ the visitor for the │                                    │
│  │ matching nodes      │                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ context.report      │                                    │
│  │ publishes the       │                                    │
│  │ problem             │                                    │
│  └─────────────────────┘                                    │
└──────────────────────────────────────────────────────────────┘

The report Object

┌──────────────────────────────────────────────────────────────┐
│  context.report({                                            │
│    node: node,          ← the location                       │
│    messageId: "id",     ← the template key                   │
│    data: { ... },       ← the interpolation                  │
│    fix(fixer) { ... }   ← the correction                     │
│  });                                                         │
│                                                              │
│  The node provides the line and column.                     │
│  The messageId provides the template.                       │
│  The fix provides the automatic correction.                 │
└──────────────────────────────────────────────────────────────┘

The Visitor Object

┌──────────────────────────────────────────────────────────────┐
│  create(context) {                                           │
│    return {                                                  │
│      CallExpression(node) { ... },    ← node type           │
│      "IfStatement > BlockStatement"(node) { ... }, ← selector│
│    };                                                        │
│  }                                                           │
│                                                              │
│  The keys are node types or selectors.                      │
│  The values are the functions that run.                     │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Rule exportsmeta and create
meta.type"problem", "suggestion", "layout"
meta.messagesThe messageId templates
meta.fixable"code" for a fixable rule
create(context)Returns the visitor object
Visitor keysAST node types or selectors
context.report{ node, messageId, data, fix }
Fixer methodsremove, replaceText, insertTextBefore, insertTextAfter

Key takeaways:

  • A custom rule exports meta and create. The meta object describes the rule, and the create method returns a visitor object whose keys are AST node types or selectors. The visitor function runs when the traversal reaches a matching node .
  • context.report publishes the problem. It accepts the node, the messageId, the data for interpolation, and the fix function. The node provides the location, and the messageId refers to a template in meta.messages .
  • The messageId form is preferred over the raw message string. The ID keeps the message in the meta object, where it can be documented, localized, and tested. The raw string still works, but the ID is the recommended form .
  • The fix function is enabled by meta.fixable: "code". Without the property, the fix is ignored. The fix function receives a fixer object and returns a fix, such as fixer.remove(node) or fixer.replaceText(node, "replacement") .
  • The selector syntax is the narrow visitor mechanism. A selector like "CallExpression[callee.object.name='console']" matches only the console calls, which means the visitor function runs for the specific pattern rather than every call .

Remember: A custom ESLint rule is a visitor and a reporter. The visitor is the object that create returns, and its keys are the AST node types or selectors that the rule cares about. The reporter is the context.report call, and it publishes the problem at the node’s location with the message template and the optional fix. The meta object is the rule’s description, and the messages are the templates that the messageId refers to. The test file is the verification, and the RuleTester runs the rule against the valid and invalid cases. The visitor finds the node, and the report publishes the problem.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!