Custom Rules
6 min readRapid overview
- Creating Custom ESLint Rules
- Why Custom Rules?
- Rule Anatomy
- Basic Structure
- Understanding the AST
- AST Explorer
- Common Node Types
- Real-World Custom Rules
- Rule 1: No Console in Production Code
- Rule 2: Enforce Named Exports
- Rule 3: Require Error Boundary Wrapper
- Rule 4: Enforce API Response Type
- Rule 5: No Magic Strings in JSX
- Creating an ESLint Plugin
- Project Structure
- package.json
- lib/index.js
- Testing Custom Rules
- Using RuleTester
- Running Tests
- Using Custom Rules
- In Flat Config
- Using Preset Config
- Local Rules (Without Publishing)
- Auto-Fix Best Practices
- Safe Fixes
- When NOT to Auto-Fix
- Interview Questions
- 1. When would you create a custom ESLint rule vs using an existing one?
- 2. How do you ensure custom rules don't slow down linting?
- 3. How do you test edge cases in custom rules?
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 Type | Represents |
|---|---|
Identifier | Variable/function names |
Literal | Strings, numbers, booleans |
CallExpression | Function calls foo() |
MemberExpression | Property access obj.prop |
FunctionDeclaration | function foo() {} |
ArrowFunctionExpression | () => {} |
IfStatement | if (...) {} |
ImportDeclaration | import 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:exitwhere 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