Custom Rules

6 min read
Rapid overview

Creating Custom ESLint Rules

Why Custom Rules?

  • Enforce domain-specific conventions
  • Prevent known antipatterns in your codebase
  • Automate code review feedback
  • Enforce architectural boundaries

Rule Anatomy

Basic Structure

// lib/rules/no-hardcoded-api-url.js
module.exports = {
  meta: {
    type: 'problem',           // 'problem', 'suggestion', or 'layout'
    docs: {
      description: 'Disallow hardcoded API URLs',
      category: 'Best Practices',
      recommended: true,
    },
    fixable: 'code',           // 'code' or 'whitespace' or null
    hasSuggestions: true,
    schema: [],                // Options schema (JSON Schema)
    messages: {
      hardcodedUrl: 'Use environment variables instead of hardcoded API URLs',
      useEnvVar: 'Replace with process.env.API_URL',
    },
  },

  create(context) {
    return {
      // AST node visitor
      Literal(node) {
        if (typeof node.value === 'string' &&
            node.value.match(/^https?:\/\/api\./)) {
          context.report({
            node,
            messageId: 'hardcodedUrl',
            fix(fixer) {
              return fixer.replaceText(node, 'process.env.API_URL');
            },
            suggest: [
              {
                messageId: 'useEnvVar',
                fix(fixer) {
                  return fixer.replaceText(node, 'process.env.API_URL');
                },
              },
            ],
          });
        }
      },
    };
  },
};

Understanding the AST

AST Explorer

Use astexplorer.net to visualize code as AST.

// This code:
const name = 'John';

// Becomes this AST:
{
  "type": "VariableDeclaration",
  "declarations": [{
    "type": "VariableDeclarator",
    "id": { "type": "Identifier", "name": "name" },
    "init": { "type": "Literal", "value": "John" }
  }],
  "kind": "const"
}

Common Node Types

Node TypeRepresents
IdentifierVariable/function names
LiteralStrings, numbers, booleans
CallExpressionFunction calls foo()
MemberExpressionProperty access obj.prop
FunctionDeclarationfunction foo() {}
ArrowFunctionExpression() => {}
IfStatementif (...) {}
ImportDeclarationimport x from 'y'
JSXElement<Component />

Real-World Custom Rules

Rule 1: No Console in Production Code

// lib/rules/no-console-in-components.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallow console.* in React components',
    },
    fixable: null,
    messages: {
      noConsole: 'Remove console.{{method}} from component code. Use a logger service instead.',
    },
  },

  create(context) {
    const filename = context.getFilename();

    // Only apply to component files
    if (!filename.match(/\.(jsx|tsx)$/)) {
      return {};
    }

    return {
      CallExpression(node) {
        if (
          node.callee.type === 'MemberExpression' &&
          node.callee.object.name === 'console'
        ) {
          context.report({
            node,
            messageId: 'noConsole',
            data: {
              method: node.callee.property.name,
            },
          });
        }
      },
    };
  },
};

Rule 2: Enforce Named Exports

// lib/rules/prefer-named-export.js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'Prefer named exports over default exports',
    },
    fixable: 'code',
    messages: {
      preferNamed: 'Prefer named exports over default exports',
    },
  },

  create(context) {
    return {
      ExportDefaultDeclaration(node) {
        // Allow in specific files (Next.js pages, etc.)
        const filename = context.getFilename();
        if (filename.match(/(page|layout|route)\.(js|ts)x?$/)) {
          return;
        }

        context.report({
          node,
          messageId: 'preferNamed',
          fix(fixer) {
            // Can only auto-fix simple cases
            if (node.declaration.type === 'FunctionDeclaration' &&
                node.declaration.id) {
              const funcName = node.declaration.id.name;
              const sourceCode = context.getSourceCode();
              const funcText = sourceCode.getText(node.declaration);

              return fixer.replaceText(
                node,
                funcText.replace('function ', 'export function ')
              );
            }
            return null;
          },
        });
      },
    };
  },
};

Rule 3: Require Error Boundary Wrapper

// lib/rules/require-error-boundary.js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'Require ErrorBoundary wrapper in page components',
    },
    messages: {
      missingErrorBoundary: 'Page components should be wrapped with ErrorBoundary',
    },
  },

  create(context) {
    const filename = context.getFilename();

    // Only check page components
    if (!filename.match(/pages?\/.*\.(jsx|tsx)$/)) {
      return {};
    }

    let hasErrorBoundary = false;

    return {
      JSXIdentifier(node) {
        if (node.name === 'ErrorBoundary') {
          hasErrorBoundary = true;
        }
      },

      'Program:exit'() {
        if (!hasErrorBoundary) {
          context.report({
            loc: { line: 1, column: 0 },
            messageId: 'missingErrorBoundary',
          });
        }
      },
    };
  },
};

Rule 4: Enforce API Response Type

// lib/rules/typed-api-response.js
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: 'Require type annotation for fetch responses',
    },
    messages: {
      untypedResponse: 'API responses must be typed. Add type assertion or use typed fetch wrapper.',
    },
  },

  create(context) {
    return {
      CallExpression(node) {
        // Check for .json() calls
        if (
          node.callee.type === 'MemberExpression' &&
          node.callee.property.name === 'json'
        ) {
          const parent = node.parent;

          // Check if result is typed
          if (
            parent.type === 'AwaitExpression' &&
            parent.parent.type === 'VariableDeclarator'
          ) {
            const declarator = parent.parent;

            // No type annotation
            if (!declarator.id.typeAnnotation) {
              context.report({
                node,
                messageId: 'untypedResponse',
              });
            }
          }
        }
      },
    };
  },
};

Rule 5: No Magic Strings in JSX

// lib/rules/no-magic-strings-jsx.js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'Disallow magic strings in JSX, use i18n or constants',
    },
    messages: {
      magicString: 'Use i18n function or constants for user-visible strings',
    },
    schema: [
      {
        type: 'object',
        properties: {
          allowedStrings: {
            type: 'array',
            items: { type: 'string' },
          },
        },
      },
    ],
  },

  create(context) {
    const options = context.options[0] || {};
    const allowedStrings = new Set(options.allowedStrings || []);

    return {
      JSXText(node) {
        const text = node.value.trim();

        if (
          text.length > 2 &&
          !allowedStrings.has(text) &&
          /[a-zA-Z]/.test(text)
        ) {
          context.report({
            node,
            messageId: 'magicString',
          });
        }
      },

      JSXExpressionContainer(node) {
        if (
          node.expression.type === 'Literal' &&
          typeof node.expression.value === 'string'
        ) {
          const text = node.expression.value.trim();

          if (
            text.length > 2 &&
            !allowedStrings.has(text) &&
            /[a-zA-Z]/.test(text)
          ) {
            context.report({
              node,
              messageId: 'magicString',
            });
          }
        }
      },
    };
  },
};

Creating an ESLint Plugin

Project Structure

eslint-plugin-mycompany/
├── package.json
├── lib/
│   ├── index.js
│   └── rules/
│       ├── no-hardcoded-api-url.js
│       ├── prefer-named-export.js
│       └── require-error-boundary.js
└── tests/
    └── rules/
        ├── no-hardcoded-api-url.test.js
        └── prefer-named-export.test.js

package.json

{
  "name": "eslint-plugin-mycompany",
  "version": "1.0.0",
  "main": "lib/index.js",
  "peerDependencies": {
    "eslint": ">=8.0.0"
  },
  "devDependencies": {
    "eslint": "^9.0.0"
  }
}

lib/index.js

const noHardcodedApiUrl = require('./rules/no-hardcoded-api-url');
const preferNamedExport = require('./rules/prefer-named-export');
const requireErrorBoundary = require('./rules/require-error-boundary');

module.exports = {
  meta: {
    name: 'eslint-plugin-mycompany',
    version: '1.0.0',
  },
  rules: {
    'no-hardcoded-api-url': noHardcodedApiUrl,
    'prefer-named-export': preferNamedExport,
    'require-error-boundary': requireErrorBoundary,
  },
  configs: {
    recommended: {
      plugins: ['mycompany'],
      rules: {
        'mycompany/no-hardcoded-api-url': 'error',
        'mycompany/prefer-named-export': 'warn',
        'mycompany/require-error-boundary': 'warn',
      },
    },
    strict: {
      plugins: ['mycompany'],
      rules: {
        'mycompany/no-hardcoded-api-url': 'error',
        'mycompany/prefer-named-export': 'error',
        'mycompany/require-error-boundary': 'error',
      },
    },
  },
};

Testing Custom Rules

Using RuleTester

// tests/rules/no-hardcoded-api-url.test.js
const { RuleTester } = require('eslint');
const rule = require('../../lib/rules/no-hardcoded-api-url');

const ruleTester = new RuleTester({
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: 'module',
  },
});

ruleTester.run('no-hardcoded-api-url', rule, {
  valid: [
    // Valid cases
    'const url = process.env.API_URL;',
    'const url = config.apiUrl;',
    'const url = "https://example.com";', // Not api.* so it's allowed
  ],

  invalid: [
    {
      code: 'const url = "https://api.mycompany.com/users";',
      errors: [{ messageId: 'hardcodedUrl' }],
      output: 'const url = process.env.API_URL;',
    },
    {
      code: 'fetch("https://api.example.com/data");',
      errors: [{ messageId: 'hardcodedUrl' }],
    },
  ],
});

Running Tests

# In your plugin directory
npx jest tests/
# or
npx mocha tests/**/*.test.js

Using Custom Rules

In Flat Config

// eslint.config.js
import mycompanyPlugin from 'eslint-plugin-mycompany';

export default [
  {
    plugins: {
      mycompany: mycompanyPlugin,
    },
    rules: {
      'mycompany/no-hardcoded-api-url': 'error',
      'mycompany/prefer-named-export': 'warn',
    },
  },
];

Using Preset Config

// eslint.config.js
import mycompanyPlugin from 'eslint-plugin-mycompany';

export default [
  mycompanyPlugin.configs.recommended,
  // ... your other configs
];

Local Rules (Without Publishing)

// eslint.config.js
import noHardcodedApiUrl from './eslint-rules/no-hardcoded-api-url.js';

export default [
  {
    plugins: {
      local: {
        rules: {
          'no-hardcoded-api-url': noHardcodedApiUrl,
        },
      },
    },
    rules: {
      'local/no-hardcoded-api-url': 'error',
    },
  },
];

Auto-Fix Best Practices

Safe Fixes

fix(fixer) {
  // Simple text replacement
  return fixer.replaceText(node, 'newText');

  // Insert before/after
  return fixer.insertTextBefore(node, 'prefix');
  return fixer.insertTextAfter(node, 'suffix');

  // Remove
  return fixer.remove(node);

  // Multiple fixes
  return [
    fixer.replaceText(node1, 'new1'),
    fixer.insertTextAfter(node2, 'addition'),
  ];
}

When NOT to Auto-Fix

  • When the fix might change behavior
  • When multiple valid fixes exist
  • When the fix requires user input

Use suggest instead for unsafe fixes:

suggest: [
  {
    messageId: 'suggestFix',
    fix(fixer) {
      return fixer.replaceText(node, 'suggested replacement');
    },
  },
],

Interview Questions

1. When would you create a custom ESLint rule vs using an existing one?

Create custom when:

  • Enforcing company-specific conventions
  • Preventing known issues specific to your codebase
  • Automating repeated code review feedback
  • Enforcing architectural boundaries

2. How do you ensure custom rules don't slow down linting?

  • Cache AST traversal results
  • Use specific node selectors instead of Program:exit where possible
  • Avoid complex regex on every node
  • Profile with TIMING=1 eslint .

3. How do you test edge cases in custom rules?

  • Use RuleTester with valid and invalid cases
  • Test boundary conditions (empty files, nested structures)
  • Test with TypeScript and JSX syntax
  • Test auto-fix output matches expected