Custom ESLint Rules: Enforce Your Own Style | Web Formatter Blog

Custom ESLint Rules: Enforce Your Own Style
A comprehensive guide to creating and implementing custom ESLint rules for your JavaScript and TypeScript projects.
Introduction to Custom ESLint Rules
ESLint has become the standard tool for linting JavaScript and TypeScript code, helping developers catch errors and enforce consistent coding styles. While ESLint comes with many built-in rules and there are numerous third-party plugins available, sometimes you need rules specific to your project or organization's needs.
This guide will walk you through the process of creating custom ESLint rules, from understanding the basics to implementing advanced functionality and sharing your rules with others.
Why Create Custom ESLint Rules?
Before diving into the technical details, let's explore why you might want to create custom ESLint rules:
- Enforce organization-specific patterns that aren't covered by existing rules
- Prevent common mistakes specific to your codebase or architecture
- Automate code reviews for style and architectural concerns
- Ensure consistency across large teams or multiple projects
- Gradually migrate from deprecated patterns or APIs
- Enforce domain-specific best practices relevant to your business
Custom rules can significantly improve code quality and developer productivity by catching issues early and automatically fixing common problems.
ESLint Basics
Before creating custom rules, it's important to understand how ESLint works under the hood.
Anatomy of an ESLint Rule
An ESLint rule consists of a few key components:
- Meta information: Documentation, configuration schema, and type
- Create function: The main function that analyzes code
- Selectors: Patterns that identify which parts of the code to examine
- Handlers: Functions that run when matching nodes are found
- Context: Provides utilities for reporting problems and accessing settings
Here's the basic structure of an ESLint rule:
module.exports = {
meta: {
type: "suggestion", // Can be "problem", "suggestion", or "layout"
docs: {
description: "Description of the rule",
category: "Best Practices",
recommended: false,
},
fixable: "code", // Or "whitespace" or null if not fixable
schema: [], // Options schema
messages: {
unexpectedFormat: "This code format is not allowed"
}
},
create: function(context) {
return {
// Selectors and their handlers
Identifier(node) {
// Rule logic
if (someCondition) {
context.report({
node,
messageId: "unexpectedFormat",
fix: function(fixer) {
// Fix logic
return fixer.replaceText(node, fixedCode);
}
});
}
}
};
}
};
Understanding AST
ESLint analyzes code by parsing it into an Abstract Syntax Tree (AST). Understanding the AST structure is crucial for writing effective rules.
The AST represents code as a tree of nodes, each corresponding to a specific syntax element. For example, a function declaration, variable assignment, or object literal would each be represented by different node types.
Tools like{" "} AST Explorer {" "} are invaluable for understanding how your code is represented in the AST.
// This code
function hello(name) {
return "Hello, " + name;
}
// Is represented as an AST with nodes like:
// - FunctionDeclaration
// - Identifier (name: "hello")
// - Params [Identifier (name: "name")]
// - BlockStatement
// - ReturnStatement
// - BinaryExpression (operator: "+")
// - Literal (value: "Hello, ")
// - Identifier (name: "name")
Creating Your First Custom Rule
Now that we understand the basics, let's create a simple custom ESLint rule.
Setting Up Your Environment
First, let's set up a project for our custom rule:
# Create a new directory
mkdir eslint-custom-rules
cd eslint-custom-rules
# Initialize a new npm project
npm init -y
# Install necessary dependencies
npm install --save-dev eslint
Create a directory structure for your rules:
mkdir -p lib/rules tests
Writing a Simple Rule
Let's create a simple rule that enforces a naming convention for
constants. We'll require all constants (variables declared with{" "}
const
) to use UPPER_SNAKE_CASE.
Create a file lib/rules/const-naming-convention.js
:
"use strict";
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Enforce UPPER_SNAKE_CASE for constants",
category: "Stylistic Issues",
recommended: false,
},
fixable: "code",
schema: [], // No options
messages: {
invalidName: "Constant '{{ name }}' should use UPPER_SNAKE_CASE"
}
},
create: function(context) {
// Helper function to check if a name is in UPPER_SNAKE_CASE
function isUpperSnakeCase(name) {
return /^[A-Z][A-Z0-9_]*$/.test(name);
}
// Helper to convert a name to UPPER_SNAKE_CASE
function toUpperSnakeCase(name) {
// Convert camelCase or PascalCase to UPPER_SNAKE_CASE
return name
.replace(/([A-Z])/g, '_$1')
.replace(/^_/, '')
.toUpperCase();
}
return {
// Look for const declarations
VariableDeclaration(node) {
// Only check 'const' declarations
if (node.kind !== 'const') return;
// Check each declarator in the declaration
node.declarations.forEach(declarator => {
// Only check identifiers (not destructuring patterns)
if (declarator.id.type === 'Identifier') {
const name = declarator.id.name;
// Skip if already in correct format
if (isUpperSnakeCase(name)) return;
// Report the issue
context.report({
node: declarator.id,
messageId: "invalidName",
data: { name },
fix: function(fixer) {
return fixer.replaceText(
declarator.id,
toUpperSnakeCase(name)
);
}
});
}
});
}
};
}
};
Testing Your Rules
Testing is crucial for ensuring your rules work correctly. ESLint provides a testing framework specifically for rules.
First, install the necessary testing dependencies:
npm install --save-dev mocha
Create a test file{" "}
tests/lib/rules/const-naming-convention.js
:
"use strict";
const rule = require("../../../lib/rules/const-naming-convention");
const RuleTester = require("eslint").RuleTester;
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 2021,
sourceType: "module"
}
});
ruleTester.run("const-naming-convention", rule, {
valid: [
// Valid cases
"const MY_CONSTANT = 5;",
"const USER_ID = 'abc123';",
"const API_KEY = getApiKey();",
"let lowercaseVar = 10;", // Not a const, so not checked
"var anotherVar = 'test';", // Not a const, so not checked
],
invalid: [
// Invalid cases
{
code: "const myConstant = 5;",
output: "const MY_CONSTANT = 5;",
errors: [{ messageId: "invalidName", data: { name: "myConstant" } }]
},
{
code: "const apiKey = 'xyz789';",
output: "const API_KEY = 'xyz789';",
errors: [{ messageId: "invalidName", data: { name: "apiKey" } }]
},
{
code: "const userId = getUserId();",
output: "const USER_ID = getUserId();",
errors: [{ messageId: "invalidName", data: { name: "userId" } }]
}
]
});
Add a test script to your package.json
:
{
"scripts": {
"test": "mocha tests/**/*.js"
}
}
Run the tests:
npm test
Advanced Rule Techniques
Now that we've created a basic rule, let's explore some more advanced techniques.
Implementing Auto-Fixers
Auto-fixers allow ESLint to automatically correct issues it finds. We already implemented a simple fixer in our previous example, but let's look at more complex fixing scenarios.
ESLint provides several fixer methods:
-
fixer.insertTextAfter(node, text)
: Insert text after a node -
fixer.insertTextBefore(node, text)
: Insert text before a node -
fixer.remove(node)
: Remove a node -
fixer.replaceText(node, text)
: Replace a node's text -
fixer.replaceTextRange(range, text)
: Replace text in a specific range
Here's an example of a more complex fixer that enforces using template literals instead of string concatenation:
// Rule to convert string concatenation to template literals
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Prefer template literals over string concatenation",
category: "Stylistic Issues",
recommended: false,
},
fixable: "code",
schema: [],
messages: {
preferTemplate: "Use template literals instead of string concatenation"
}
},
create: function(context) {
return {
BinaryExpression(node) {
// Only target string concatenation
if (node.operator !== '+') return;
// Check if at least one operand is a string
const hasStringLiteral = node.left.type === 'Literal' && typeof node.left.value === 'string' ||
node.right.type === 'Literal' && typeof node.right.value === 'string';
if (!hasStringLiteral) return;
// Helper function to convert the entire expression to a template literal
function convertToTemplate(expr) {
if (expr.type !== 'BinaryExpression' || expr.operator !== '+') {
// Base case: not a concatenation
if (expr.type === 'Literal' && typeof expr.value === 'string') {
return expr.value;
} else {
// For non-string expressions, wrap in template literals
const sourceCode = context.getSourceCode();
return "\\${" + sourceCode.getText(expr) + "}";
}
}
// Recursive case: concatenation
const left = convertToTemplate(expr.left);
const right = convertToTemplate(expr.right);
return left + right;
}
context.report({
node,
messageId: "preferTemplate",
fix: function(fixer) {
const template = '`' + convertToTemplate(node) + '`';
return fixer.replaceText(node, template);
}
});
}
};
}
};
Using Rule Context
The context object provides access to the source code, settings, and other useful utilities:
-
context.getSourceCode()
: Access the source code object -
context.getFilename()
: Get the current filename -
context.getScope()
: Access the current scope -
context.settings
: Access shared settings from the ESLint configuration -
context.options
: Access rule options
Here's an example of using context to create a rule that enforces file naming conventions based on the exported component name:
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Ensure React component files are named after the main exported component",
category: "Best Practices",
recommended: false,
},
schema: [],
messages: {
mismatchedName: "Filename '{{ filename }}' should match exported component name '{{ componentName }}'"
}
},
create: function(context) {
// Extract the base filename without extension
const filename = context.getFilename();
const baseFilename = filename.split('/').pop().split('.').shift();
// Skip if filename is index
if (baseFilename === 'index') return {};
return {
// Check default exported component declarations
ExportDefaultDeclaration(node) {
let componentName;
// Handle different export types
if (node.declaration.type === 'Identifier') {
// export default ComponentName;
componentName = node.declaration.name;
} else if (
node.declaration.type === 'FunctionDeclaration' ||
node.declaration.type === 'ClassDeclaration'
) {
// export default function ComponentName() {...}
// export default class ComponentName extends React.Component {...}
componentName = node.declaration.id?.name;
}
if (componentName && componentName !== baseFilename) {
context.report({
node,
messageId: "mismatchedName",
data: {
filename: baseFilename,
componentName
}
});
}
}
};
}
};
Configurable Rule Options
Making your rules configurable increases their flexibility.
Options are defined in the rule's schema and accessed via{" "}
context.options
.
Let's modify our constant naming rule to support different naming conventions:
module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Enforce naming convention for constants",
category: "Stylistic Issues",
recommended: false,
},
fixable: "code",
schema: [
{
type: "object",
properties: {
convention: {
enum: ["upper-snake", "camel", "pascal"],
default: "upper-snake"
},
ignorePattern: {
type: "string"
}
},
additionalProperties: false
}
],
messages: {
invalidName: "Constant '{{ name }}' should use {{ convention }} naming convention"
}
},
create: function(context) {
const options = context.options[0] || {};
const convention = options.convention || "upper-snake";
const ignorePattern = options.ignorePattern ? new RegExp(options.ignorePattern) : null;
// Validation functions for each convention
const validators = {
"upper-snake": name => /^[A-Z][A-Z0-9_]*$/.test(name),
"camel": name => /^[a-z][a-zA-Z0-9]*$/.test(name),
"pascal": name => /^[A-Z][a-zA-Z0-9]*$/.test(name)
};
// Conversion functions for each convention
const converters = {
"upper-snake": name => {
return name
.replace(/([A-Z])/g, '_$1')
.replace(/^_/, '')
.toUpperCase();
},
"camel": name => {
return name
.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
.replace(/^[A-Z]/, c => c.toLowerCase());
},
"pascal": name => {
return name
.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
.replace(/^[a-z]/, c => c.toUpperCase());
}
};
return {
VariableDeclaration(node) {
if (node.kind !== 'const') return;
node.declarations.forEach(declarator => {
if (declarator.id.type === 'Identifier') {
const name = declarator.id.name;
// Skip if name matches ignore pattern
if (ignorePattern && ignorePattern.test(name)) return;
// Skip if already in correct format
if (validators[convention](name)) return;
context.report({
node: declarator.id,
messageId: "invalidName",
data: {
name,
convention: convention.replace('-', ' ')
},
fix: function(fixer) {
return fixer.replaceText(
declarator.id,
converters[convention](name)
);
}
});
}
});
}
};
}
};
This rule can now be configured in your ESLint config:
{
"rules": {
"const-naming-convention": ["error", {
"convention": "camel",
"ignorePattern": "^__"
}]
}
}
Creating ESLint Plugins
Once you've created several custom rules, you might want to package them as an ESLint plugin to share with your team or the community.
Plugin Structure
An ESLint plugin is an npm package that exports an object with rules and configurations. Here's a basic structure:
// index.js
module.exports = {
rules: {
"const-naming-convention": require("./lib/rules/const-naming-convention"),
"prefer-template": require("./lib/rules/prefer-template"),
// Add more rules here
},
configs: {
recommended: {
plugins: ["your-plugin-name"],
rules: {
"your-plugin-name/const-naming-convention": "error",
"your-plugin-name/prefer-template": "warn"
}
}
}
};
Your package.json should include:
{
"name": "eslint-plugin-your-plugin-name",
"version": "1.0.0",
"description": "Custom ESLint rules for your organization",
"main": "index.js",
"keywords": [
"eslint",
"eslintplugin",
"eslint-plugin"
],
"peerDependencies": {
"eslint": ">=6.0.0"
}
}
Publishing Your Plugin
Once your plugin is ready, you can publish it to npm:
# Login to npm
npm login
# Publish the package
npm publish
For internal use, you can also publish to a private npm registry or use it directly from a Git repository:
{
"dependencies": {
"eslint-plugin-your-plugin-name": "github:your-org/eslint-plugin-your-plugin-name"
}
}
Integrating Custom Rules
Now that you've created custom rules, let's see how to integrate them into your projects.
Project Setup
For local rules (not packaged as a plugin), you can use ESLint's{" "}
--rulesdir
option:
eslint --rulesdir ./lib/rules --rule "const-naming-convention: error" src/
For a more permanent setup, you can create a custom configuration file:
// .eslintrc.js
module.exports = {
rules: {
// Local rules
"const-naming-convention": "error"
},
// Load rules from a custom directory
plugins: ["eslint-plugin-local"],
settings: {
"eslint-plugin-local": {
rulesDir: "./lib/rules"
}
}
};
For rules packaged as a plugin, installation is straightforward:
npm install --save-dev eslint-plugin-your-plugin-name
// .eslintrc.js
module.exports = {
plugins: ["your-plugin-name"],
extends: ["plugin:your-plugin-name/recommended"],
rules: {
// Override specific rules
"your-plugin-name/const-naming-convention": ["error", {
"convention": "pascal"
}]
}
};
CI Integration
Integrating your custom rules into CI pipelines ensures consistent enforcement across your team:
# GitHub Actions example
name: ESLint
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
- run: npm ci
- run: npm run lint
# Assuming you have a lint script in package.json:
# "lint": "eslint --rulesdir ./lib/rules src/"
Editor Integration
Custom rules work seamlessly with editor integrations like VS Code's ESLint extension:
- Install the ESLint extension for your editor
- Configure the extension to use your project's ESLint configuration
- Add any custom paths needed for local rules
For Visual Studio Code, you might need to add this to your settings:
{
"eslint.options": {
"rulePaths": ["./lib/rules"]
}
}
Real-World Examples
Let's explore some real-world examples of custom ESLint rules that solve specific problems.
Style Enforcement Rules
This rule enforces that import statements are grouped and ordered in a specific way:
// enforce-import-grouping.js
module.exports = {
meta: {
type: "suggestion",
fixable: "code",
docs: {
description: "Enforce import grouping and ordering",
category: "Stylistic Issues"
},
messages: {
incorrectOrder: "Imports should be grouped and ordered: third-party, then internal, then relative"
}
},
create(context) {
return {
Program(node) {
const importDeclarations = node.body.filter(n => n.type === "ImportDeclaration");
if (importDeclarations.length <= 1) return;
// Group imports
const thirdParty = [];
const internal = [];
const relative = [];
importDeclarations.forEach(imp => {
const source = imp.source.value;
if (source.startsWith(".")) {
relative.push(imp);
} else if (source.startsWith("@company/")) {
internal.push(imp);
} else {
thirdParty.push(imp);
}
});
// Check if they're in the correct order
const correctOrder = [...thirdParty, ...internal, ...relative];
const currentOrder = importDeclarations;
// Compare the current order with the correct order
let isCorrectOrder = true;
for (let i = 0; i < correctOrder.length; i++) {
if (correctOrder[i] !== currentOrder[i]) {
isCorrectOrder = false;
break;
}
}
if (!isCorrectOrder) {
context.report({
node: importDeclarations[0],
messageId: "incorrectOrder",
fix: fixer => {
const fixes = [];
// Remove all existing imports
importDeclarations.forEach(imp => {
fixes.push(fixer.remove(imp));
});
// Add them back in the correct order
correctOrder.forEach((imp, i) => {
const text = context.getSourceCode().getText(imp);
if (i === 0) {
fixes.push(fixer.insertTextBefore(importDeclarations[0], text));
} else {
fixes.push(fixer.insertTextAfter(
correctOrder[i - 1],
"\n" + text
));
}
});
return fixes;
}
});
}
}
};
}
}
Security Rules
This rule helps prevent potential security vulnerabilities by ensuring all database queries use parameterized queries:
// no-string-concat-sql.js
module.exports = {
meta: {
type: "problem",
docs: {
description: "Disallow string concatenation in SQL queries to prevent SQL injection",
category: "Security"
},
messages: {
noStringConcatSQL: "Avoid string concatenation in SQL queries. Use parameterized queries instead."
}
},
create(context) {
// Helper to check if a node is likely a SQL query
function isSQLQuery(node) {
if (node.type !== "Literal" || typeof node.value !== "string") return false;
const sql = node.value.toLowerCase().trim();
return sql.startsWith("select") ||
sql.startsWith("insert") ||
sql.startsWith("update") ||
sql.startsWith("delete") ||
sql.startsWith("create") ||
sql.startsWith("alter");
}
return {
// Check for SQL literals in binary expressions (string concatenation)
BinaryExpression(node) {
if (node.operator !== "+") return;
// Check if either side is a SQL query
if (isSQLQuery(node.left) || isSQLQuery(node.right)) {
context.report({
node,
messageId: "noStringConcatSQL"
});
}
},
// Check method calls for query methods with concatenated strings
CallExpression(node) {
if (node.callee.type !== "MemberExpression") return;
const methodName = node.callee.property.name;
if (["query", "execute", "executeSql"].includes(methodName)) {
// Check if first argument is a binary expression with +
const firstArg = node.arguments[0];
if (firstArg && firstArg.type === "BinaryExpression" && firstArg.operator === "+") {
context.report({
node: firstArg,
messageId: "noStringConcatSQL"
});
}
}
}
};
}
}
Best Practices
Here are some best practices to follow when creating custom ESLint rules:
- Focus on specific problems: Each rule should address a single concern
- Write thorough tests: Test both valid and invalid code scenarios
- Document your rules clearly: Explain what the rule does, why it's needed, and how to fix violations
- Make rules fixable when possible: Auto-fixes save developer time and improve adoption
- Use clear, descriptive error messages: Help developers understand why their code was flagged
- Consider performance: Complex rules can slow down the linting process
- Provide configuration options: Allow for customization to fit different project needs
- Check for false positives: Rules should minimize incorrect flagging of valid code
Conclusion
Custom ESLint rules are a powerful way to enforce coding standards specific to your project or organization. By creating tailored rules, you can:
- Enforce consistent code style across your team
- Catch common errors and anti-patterns early
- Automate aspects of code review
- Guide developers toward best practices
- Improve code quality and maintainability
With the knowledge from this guide, you now have the tools to create, test, and share custom ESLint rules that address your specific needs. Start small with a single rule that solves a real problem in your codebase, and build from there as you become more comfortable with the ESLint API and AST structure.