Custom ESLint rules: strengths and limitations
Learn what makes ESLint custom rules so powerful and dive into the Abstract Syntax Tree to as we analyze a custom ESLint rule solving a real-world problem.

Have you ever encountered business logic bugs that slip through the review process or found business logic inconsistencies in your codebase?
ESLint, the popular JavaScript linter, not only helps you maintain high-quality code but also allows you to define custom rules tailored to your company and project needs.
In this post, I’ll share my journey as a junior developer exploring custom ESLint rules, their power, and their limitations.
A real-world problem that we needed to solve
I am working on a large e-commerce application where we use Server Side Rendering (SSR) to keep page load performance fast. A common challenge with SSR is server/client consistency. Since we use React, a difference there would lead to a hydration mismatch which React cannot always gracefully patch leading to a degraded user experience.
One such case is when you are dealing with dates and timezones, for example to display the release date and time of our latest fancy sneakers. Since there’s no HTTP header that can tell us the customer’s timezone, we’ve made the product decision to show the timezone based on our website domains. (For example CET for our customers browsing on zalando.de or zalando.fr). But what happens when you format a date without specifying the timezone?
const releaseDate = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short",}).format(new Date());
When this code runs on my browser, releaseDate would be ‘Thursday 16 January 2025 at 11:12’ - but what happens when this runs on a Node.JS server in Ireland? It would return ‘Thursday 16 January 2025 at 10:12’ - leading to a server/client mismatch and an hydration error.

This problem can be easily fixed by passing the timeZone
parameter to the Intl.DateTimeFormat function. However, on a large codebase, this is hard to enforce consistently because the timeZone parameter is not mandatory. What if we could enforce it ourselves with an ESLint rule? After a quick search, we could not find an existing configuration that enforces it so we decided to write our own custom ESLint rule!
Diving into the world of Abstract Syntax Tree (AST)
Looking at our existing custom ESLint rules and ESLint documentation, I quickly realized that I would need to understand how JavaScript syntax is parsed based on its Abstract Syntax Tree (AST). You can think of an AST as a hierarchical map of the structure of your code, where each block (such as variables or functions) is a node. The AST captures the syntax without all the details of the raw code, such as semicolons or formatting.

ASTs are commonly used in compilers, interpreters, and tools such as linters or code formatters to analyze, optimize, and transform code. In our case, it will help us to build our custom ESLint rule.
The best place to visualize an AST is the AST Explorer
Building our custom ESLint rule
Step 1: Create the rule skeleton in a new file:
/** @type {import('eslint').Rule.RuleModule} */export const enforceTimezoneRule = { meta: { type: "problem", docs: { description: "Enforce timeZone in Intl.DateTimeFormat", }, schema: [], }, create(context) { return {}; },};
/** @type {import('eslint').Linter.Config} */export const enforceTimezoneConfig = { plugins: { local: { rules: { "enforce-datetimeformat-timezone": enforceTimezoneRule }, }, }, rules: { "local/enforce-datetimeformat-timezone": "error", },};
You can then import enforceTimezoneConfig
in your ESLint configuration file.
In our case, the rule is a problem that needs to be fixed, unfortunately not automatically by ESLint.
Step 2: Detecting Intl.DateTimeFormat
We can hook into the AST and listen for MemberExpression nodes, which represent object property accesses (like Intl.DateTimeFormat) and check if the object is Intl and the property is DateTimeFormat.
create(context) { return { MemberExpression(node) { if ( node.object?.name === "Intl" && node.property.name === "DateTimeFormat") { // ... } }, }; },
Step 3: Validating arguments
Let’s start with the basics as we know we would need 2 arguments for the DateTimeFormat: the date itself and the options.
MemberExpression(node) { if (node.object?.name === "Intl" && node.property.name === "DateTimeFormat") { if (node.parent.arguments.length !== 2) { context.report({ node, message: `Using ${node.property.name} without timeZone is not safe. Please provide an options parameter with 'timeZone'.`, }); } } },
Step 4: Checking for the timeZone parameter
Now we need to check if the options parameter contains the timeZone option. Since the nesting of our rule is starting to grow, we can create a helper function at this point. I won’t go into the details of the helper function but will show below how it works with specific examples below.
/** * @param {ObjectExpression} optionsNode * @param {import('eslint').Rule.RuleContext} context */function optionsParameterContainsTimezone(optionsNode, context) { if (optionsNode.type === "ObjectExpression") { if ( optionsNode.properties.some( (property) => property.key.type === "Identifier" && property.key.name === "timeZone" && property.value.type === "Literal", ) // when it's hard coded as a Literal ) { return true; } else if ( optionsNode.properties.some( (property) => property.key.type === "Identifier" && property.key.name === "timeZone" && property.value.type === "Identifier", ) // when timeZone is passed in a variable ) { return true; } } else if (optionsNode.type === "Identifier") { // when it's an object const variableName = optionsNode.name; const variable = context.sourceCode.getScope(optionsNode).variables.find((v) => v.name === variableName); if (!variable) { return true; } else if (variable) { // object found in the same function-scope where Intl.DateTimeFormat was called const { references } = variable; for (const reference of references) { if (reference.writeExpr && reference.writeExpr.type === "ObjectExpression") { // search in function-scope for a variable declaration (ObjectExpression) if ( reference.writeExpr.properties.some( (property) => property.key.type === "Identifier" && property.key.name === "timeZone" && property.value.type === "Literal", ) ) { return true; } else return false; } else return true; // when object is defined in another file } } } return false;}
and now we can use it in our rule which looks like this end to end:
/** @type {import('eslint').Rule.RuleModule} */export const enforceTimezoneRule = { meta: { type: "problem", docs: { description: "Enforce timeZone in Intl.DateTimeFormat", }, schema: [], }, create(context) { return { MemberExpression(node) { if (node.object?.name === "Intl" && node.property.name === "DateTimeFormat") { if (node.parent.arguments.length !== 2) { context.report({ node, message: `Using ${node.property.name} without timeZone is not safe. Please provide an options parameter with 'timeZone'.`, }); } else { const optionsNode = node.parent.arguments[1]; if (!optionsParameterContainsTimezone(optionsNode, context)) { context.report({ node, message: `Ensure ${node.property.name} is called with timeZone parameter in options.`, }); } } } }, }; },};
Step 4: Validating and testing the rule
Since there are many ways to call Intl.DateTimeFormat, we needed to validate the rule for different cases.
When the options parameter is an Object Literal
If the second argument (optionsNode) is a hardcoded object (ObjectExpression in AST terms), then we iterate through its properties. In those properties we are looking for the key name timeZone
and the type of Literal string
or an Identifier variable
:
// ✅ Validnew Intl.DateTimeFormat('en-US', { timeZone: 'UTC' });
When the options parameter is a variable
If the second argument is a variable (Identifier in AST terms), find the variable declaration in the current scope using context.getScope(). Then check the variable’s references to determine where it is assigned a value.
// ✅ Validconst options = { timeZone: 'UTC' };new Intl.DateTimeFormat('en-US', options);
When the options parameter is an object defined in another file
If the variable’s value comes from another file or an undefined scope, then we assume it’s safe and return true. I will cover this in custom rules’s limitations below.
// ✅ Validimport options from './config';new Intl.DateTimeFormat('en-US', options);
Any other case is considered invalid and unsafe
// ❌ Invalidnew Intl.DateTimeFormat('en-US', {}); // Missing "timeZone"const options = { style: 'short' };new Intl.DateTimeFormat('en-US', options); // No "timeZone" in variable
It’s also important to write unit tests for your custom rule as it helps documenting it and preventing regressions.
For a more in depth tutorial, I would recommend the ESLint documentation
Limitations
While custom ESLint rules are incredibly powerful and flexible, they are not without their challenges and limitations. It’s important to understand these limitations in order to set realistic expectations and make informed decisions about when to use them:
-
Complexity and Maintenance: Creating custom ESLint rules involves delving into AST structures, which can become complex, especially for nuanced or highly specific requirements. Once implemented, these rules require ongoing maintenance to ensure they remain compatible with future updates to ESLint, JavaScript, or TypeScript syntax.
-
Performance Considerations: Custom ESLint rules can introduce overhead during linting, especially if they analyze large codebases or perform exhaustive checks. Poorly optimized rules can slow down development workflows.
-
Limited Scope: ESLint rules primarily focus on static analysis of source code. They cannot enforce runtime behavior or validate dependencies outside the scope of the analyzed file, such as configurations in external files.
-
Not a Silver Bullet: Custom ESLint rules are not a one-size-fits-all solution. They cannot resolve architectural or design issues in your application. Instead, they should complement other practices, such as peer reviews and comprehensive testing.
By understanding these limitations, you can better decide when and how to use custom ESLint rules. While they offer significant benefits in enforcing coding standards and catching specific errors, they should be used judiciously and always as part of a broader quality assurance strategy.
Conclusion
Custom ESLint rules empower teams to enforce project-specific standards, they help catch bugs early and ensure code consistency. While they are powerful, they cannot catch every edge case, so use them alongside other tools and practices.
Ready to level up your code quality? Start experimenting with ASTs and create your own rules today!
