Enhance code quality and performance with ESLint

Krishnapriya S

Krishnapriya S

July 25, 2023

Introduction

ESLint is a widely adopted JavaScript linter, that analyzes and enforces coding rules and guidelines. ESLint empowers developers by detecting errors, promoting best practices, and enhancing code readability. This blog explores the fundamental concepts of ESLint, from installing the necessary dependencies, to customizing rules, and integrating these custom rules into your host projects.

Configure ESLint on a project

To set up ESLint in your host project, install the ESLint package under devDependencies as it is only used for development and not in production:

1  npm install -D eslint
2  #or
3  yarn add -D eslint

Now, you need to provide the required configurations for ESLint. You can generate your ESLint config file using any of the below commands:

1  npx eslint --init
2  #or
3  yarn run eslint --init

This will prompt multiple options. You can proceed by selecting options that suit your use case. This generates a config file called .eslintrc in the format you selected (.json, .js, etc). Here is an example of .eslintrc.js:

1module.exports = {
2  root: true,
3  env: {
4    node: true,
5    browser: true,
6    es2021: true,
7  },
8  parserOptions: {
9    ecmaVersion: 2021,
10    sourceType: "module",
11  },
12  extends: ["eslint:recommended", "plugin:react/recommended"],
13  plugins: ["react"],
14  rules: {
15    "no-console": "warn",
16    "no-unused-vars": "error",
17  },
18  settings: {
19    react: {
20      version: "detect",
21    },
22  },
23};
  • The root property is set to true to indicate that this is the root configuration file and should not inherit rules from parent directories.

  • The env property specifies the target environments where the code will run, including node, browser, and es2021 for ECMAScript 2021 features.

  • In parserOptions, we specify JavaScript options like JSX support or ECMA version.

  • The plugins property specifies the ESLint plugins to be used. Let us say you are working on a React project. You want your code to follow some React best practices and React-specific rules. You can achieve this by adding eslint-plugin-react.

  • The extends property includes an array of preset configurations to extend from, like eslint:recommended and plugin:react/recommended.

  • The settings property includes additional settings for specific plugins. In this case, we set the React version to detect for the React plugin, so that the React version is detected from our package.json.

  • In rules, we specify custom rule configurations for the codebase. For instance, the rule no-unused-vars helps identify and throw errors for unused variables in your code, while no-console warns against using console.log() statements in production code. All pre-existing rules are available in this documentation by ESLint. Rules have three error levels:

    1rules: {
    2  "no-console": "warn", // Can also use 1
    3  "no-unused-vars": 2, // Can also use "error"
    4  "no-alert": "off", // Can also use 0
    5}
    • “error” or 2: This will turn on the rule as an error. This means that ESLint will report violations of this rule as errors. Rules are typically set to error to enforce compliance with the rule during continuous integration testing, pre-commit checks, and pull request merging because doing so causes ESLint to exit with a non-zero exit code.

    • “warn” or 1: If you don’t want to enforce compliance with a rule but would still like ESLint to report the rule’s violations, set the severity to warn. This will report violations of this rule as warnings.

    • “off” or 0: This means the rule is turned off and will not be enforced. This can be useful if you want to disable a specific rule temporarily throughout your project or if you don't find a particular rule relevant to your project.

Custom rules

While ESLint comes bundled with a vast array of built-in rules, its true potential lies in the ability to create custom rules tailored to your project's unique requirements.

At the heart of every ESLint rule lies a well-crafted JavaScript object that defines its behavior. But, before we dive into the basic structure of a custom ESLint rule, there is something you need to get familiar with, called an AST (Abstract Syntax Tree).

Abstract Syntax Tree (AST)

AST can be thought of as an interpreter, that dissects your code and represents it in a tree-like structure of interconnected nodes. An ESLint parser converts code into an abstract syntax tree that ESLint can evaluate. Consider the following JavaScript code snippet:

1const sum = (x, y) => x + y;

This is how its AST looks:

AST Demo

In this example, the root node is the Program node, which represents the entire code file. Within it, there are several nested nodes like, VariableDeclarator, ArrowFunctionExpression and so on, each dedicated to different parts of the code. As demonstrated in the animation, when hovering over each node, the corresponding code segment on the LHS is highlighted. To explore and interact with both code and its AST, you can utilize the AST Explorer tool.

Basic structure of a custom rule

Now that you know what AST is, let us learn how these trees help us in creating a custom rule. You will need to analyze the code's tree structure to find out which node is to be chosen as the visitor node. A visitor node represents the target node that is visited or traversed during the linting process.

To understand further, let us take into account a simple rule and try to build it. Let us implement no-var rule. This rule encourages the use of let and const keywords, instead of var keyword to declare variables. This usage promotes block scoping and prevents any re-declarations.

The AST for a code snippet containing var would look like this:

Var Keyword AST Demo

So, the node we are interested in examining is the VariableDeclaration node. Hence, this will be our visitor node. This node contains an attribute called kind, which contains the value var, let, or const depending on the keyword used. All we have to do is verify whether it is a var and then throw an error for that particular node.

We want this logic to run on all VariableDeclaration nodes:

1if (node.kind === "var") {
2  // raise error
3}

Let us embed that logic inside an ESLint rule object:

1module.exports = {
2  meta: {
3    type: "problem", // Can be: "problem", "suggestion" or "layout".
4    docs: {
5      description: "Disallow the use of var.",
6      category: "Best Practices",
7    },
8  },
9  create: context => ({
10    VariableDeclaration(node) {
11      if (node.kind === "var") {
12        context.report({ node, message: "Using var is not allowed." });
13      }
14    },
15  }),
16};
  • The meta object provides metadata about your rule, including its type, description, category, etc.
  • The create function is the entry point for your rule implementation. It is called by ESLint and provides the context object, which allows you to interact with the code being analyzed.
  • Within the create function, you can define one or more visitor methods that target specific node types in the AST. The rule logic will be defined inside the visitor methods. In our example, the logic needs to be run only on all VariableDeclaration nodes. Thus VariableDeclaration is the only visitor method defined.
  • You can check for violations and report errors using context.report().

This is an example of how your rule would throw an error to that particular node, once integrated into your project:

ESlint Error Example

Testing ESLint rules

Rule testing in ESLint involves verifying the behavior of custom rules by providing sample code snippets that should trigger violations (invalid cases) and code snippets that should pass without violations (valid cases). RuleTester is an ESLint utility that simplifies the process of defining and running such tests.

Configuring RuleTester

First, you need to create a RuleTester instance. You can fine-tune the parser options, environments, and other configurations when you create the instance:

1const { RuleTester } = require("eslint");
2
3const ruleTester = new RuleTester({
4  parserOptions: {
5    ecmaFeatures: { jsx: true },
6    ecmaVersion: 2020,
7    sourceType: "module",
8  },
9});

Writing test cases

Now, you can use RuleTester's run() method to craft scenarios in the valid and invalid arrays to cover all possible code variations, which will be tested against your rule. Also, you can provide the expected error message, as the message property in errors. Your basic test will look something like this, for the no-var rule you implemented:

1const { RuleTester } = require("eslint");
2
3const rule = require("../rules/no-var");
4
5const ruleTester = new RuleTester({
6  parserOptions: {
7    ecmaFeatures: { jsx: true },
8    ecmaVersion: 2020,
9    sourceType: "module",
10  },
11});
12
13ruleTester.run("no-var", rule, {
14  valid: ["let x = 10;", "const x = 10;"],
15  invalid: [
16    {
17      code: "var x = 10;",
18      errors: [{ message: "Using var is not allowed." }],
19    },
20  ],
21});
22
23console.log("Completed all tests for no-var rule");

Diving deeper into custom rule creation

Let us create another rule, that enforces the use of strict equality (===) over loose equality (==). Strict equality provides more accurate comparison results and helps prevent potential bugs caused by type coercion.

Basic implementation

  1. Create a new file called strict-equality.js.

  2. Define the rule by providing a type, description, and create function to implement our logic.

    1module.exports = {
    2  meta: {
    3    type: "problem",
    4    docs: {
    5      description: "Enforce the use of strict equality.",
    6      category: "Best Practices",
    7    },
    8  },
    9  create: context => ({
    10    //Implementation logic.
    11  }),
    12};
  3. Now we need to figure out the visitor node we need. This is where you can use AST Explorer. Let us consider the below example:

    1if (a == b) {
    2  //Do something
    3}

    We can see that the AST notation for the same looks something like this:

    1{
    2  "type": "Program",
    3  "body": [
    4    {
    5      "type": "IfStatement",
    6      "test": {
    7        "type": "BinaryExpression",
    8        "operator": "==",
    9        "left": {
    10          "type": "Identifier",
    11          "name": "a"
    12        },
    13        "right": {
    14          "type": "Identifier",
    15          "name": "b"
    16        }
    17      },
    18      // Remaining attributes
    19    }
    20  ]
    21}

    From this, it is evident that the node we are interested in has the type BinaryExpression. All we have to check is whether the operator for this node is "==" and then throw an error for that particular node.

  4. Let us write this logic into our create function, and report the error with a suitable message:

    1create: context => ({
    2  BinaryExpression(node) {
    3   if (node.operator !== "==") return;
    4
    5   context.report({
    6     node,
    7     message: "Use strict equality instead of loose equality.",
    8   });
    9  },
    10}),

Implementing automatic fix

Right now, all our rule does is detect any loose equalities and show the error message for that line. Let us add some logic to provide an automatic fix for the detected errors. To achieve this, include the fix attribute in the context.report() method. We can create a string representing the corrected code and utilize the replaceText function provided by the context.fixer object to replace the specific node with the modified string. To know about all such functions offered by the fixer object, please check this documentation, on applying fixes.

Now, we need to create a string to replace the node with. Inspect this portion of the AST we generated earlier:

1"type": "BinaryExpression",
2"operator": "==",
3"left": {
4  "type": "Identifier",
5  "name": "a"
6},
7"right": {
8  "type": "Identifier",
9  "name": "b"
10}

The LHS and RHS of the operands can be accessed as, node.left and node.right respectively. The value of these operands can be fetched from the name attribute and our fix string can be constructed like this:

1`${node.left.name} === ${node.right.name}`;

Hence adding this logic to our rule:

1module.exports = {
2  meta: {
3    // Other properties
4    fixable: "code", // Include this, when your rule provides a fix.
5  },
6  create: context => ({
7    BinaryExpression(node) {
8      if (node.operator !== "==") return;
9
10      context.report({
11        node,
12        message: "Use strict equality instead of loose equality.",
13        fix: fixer => fixer.replaceText(
14          node,
15          `${node.left.name} === ${node.right.name}`
16        );
17      });
18    },
19  }),
20};

But, what if the LHS or RHS contains expressions, like these:

1if(array[index].type == a)

In that case, node.left will have more nested nodes:

Left Operator AST

Now, we can't just proceed by using node.left.name. So, how do we make sure that we don't lose any data? The getSourceCode() function is a utility provided by ESLint that allows you to retrieve the source code corresponding to a specific node in the context. We can obtain the source code as a string by using getText() function on the node. So for the above example, we can write:

1context.getSourceCode().getText(node.left); // Returns `array[index].type`

Now, let us modify our create function to handle this edge case and our completed rule would look like this:

1module.exports = {
2  meta: {
3    type: "problem",
4    docs: {
5      description: "Enforce the use of strict equality.",
6      category: "Best Practices",
7    },
8    fixable: "code",
9  },
10  create: context => ({
11    BinaryExpression(node) {
12      if (node.operator !== "==") return;
13
14      context.report({
15        node,
16        message: "Use strict equality instead of loose equality.",
17        fix: fixer => {
18          const leftNode = context.getSourceCode().getText(node.left);
19          const rightNode = context.getSourceCode().getText(node.right);
20
21          return fixer.replaceText(node, `${leftNode} === ${rightNode}`);
22        },
23      });
24    },
25  }),
26};

Adding tests for the rule

In our earlier sections, you saw how to write tests for your custom rule. Now, let us implement the same for our strict-quality rule. We need to add valid and invalid cases as strings to the respective arrays. Inside, the invalid array, you can make use of the output attribute to provide the expected fixed code for that particular invalid case. So our tests will look like this:

1const { RuleTester } = require("eslint");
2
3const rule = require("../rules/strict-equality");
4
5const ruleTester = new RuleTester({
6  parserOptions: {
7    ecmaFeatures: { jsx: true },
8    ecmaVersion: 2020,
9    sourceType: "module",
10  },
11});
12
13const message = "Use strict equality instead of loose equality.";
14
15ruleTester.run("strict-equality", rule, {
16  valid: [
17    "if (a === b) {}",
18    "if (a === b) alert(1)",
19    "if (a === b) { alert(1) }",
20  ],
21  invalid: [
22    {
23      code: "if (a == b) {}",
24      errors: [{ message }],
25      output: "if (a === b) {}",
26    },
27    {
28      code: "if (getUserRole(user) == Roles.DEFAULT) grantAccess(user);",
29      errors: [{ message }],
30      output: "if (getUserRole(user) === Roles.DEFAULT) grantAccess(user);",
31    },
32  ],
33});
34
35console.log("Completed all tests for strict-equality rule");

Custom ESLint plugins

In real-world scenarios, we will have multiple custom rules and configurations that we want to enforce consistently across our projects. This is where custom ESLint plugins become invaluable, as they allow us to bundle and package all these elements into a single plugin. In the Neeto eco-system, we use our custom plugin, eslint-plugin-neeto, to maintain a uniformly structured codebase.

In this section, we will create a custom ESLint plugin for the custom rules we created, and learn how to integrate it into our projects.

Getting started with a custom plugin

  1. Create a new directory and initialize a new npm package for your plugin. The package name should always follow the naming format, eslint-plugin-* :

    1mkdir eslint-plugin-custom
    2cd eslint-plugin-custom
    3npm init -y
  2. Arrange our previously defined rules and tests in this folder structure:

    1eslint-plugin-custom
    2├── package.json
    3├── index.js
    4├── src
    5│   └── rules
    6│     └── no-var.js
    7│     └── strict-equality.js
    8│   └── tests
    9│     └── index.js
    10│     └── no-var.js
    11│     └── strict-equality.js
    12└── README.md
  3. Let us add ESLint as a devDependency in our plugin.

    1npm install -D eslint

Adding and exporting custom rules

Copy the rules we created into, no-var.js and strict-equality.js. Now, how do we help the plugin find our rules? You can add the following to index.js in the plugin's root directory:

1module.exports = {
2  rules: {
3    "no-var": require("./src/rules/no-var"),
4    "strict-equality": require("./src/rules/strict-equality"),
5  },
6};

By configuring the index file in this way, you ensure that ESLint recognizes and associates your custom rule with the specified name, making it accessible in ESLint configurations.

Adding test files

In a similar manner to how rules are handled, we can establish a unified entry point for our tests in src/tests/index.js:

1require("./no-var.js");
2require("./strict-equality.js");

You can either set up any test framework of your choice to run the tests or run them directly using:

1node src/tests

Explore the complete code for the custom plugin, containing both the rules and their corresponding tests that we have developed this far, in eslint-plugin-custom.

Integrating the custom plugin

You saw in earlier sections that, it is possible to test your rules using RuleTester. While this method helps you specify all the edge cases and test your rules against them, it is not that easy, to think of all possible edge cases. For this, we need to run our rules in real projects and we will have to achieve this without publishing our package to the remote registry right away.

  • Integrate and test locally using yalc

    Yalc acts as a very simple local repository for your locally developed packages that you want to share across your local environment. Let us see how we can use yalc to test our custom plugin on host projects.

    1. Install yalc globally:

      1npm install -g yalc
    2. Navigate to the root directory of your custom plugin and publish it to the local yalc store:

      1cd eslint-plugin-custom
      2yalc publish
      
    3. Navigate to the root directory of the host project. Add the custom plugin using yalc:

      1cd my-host-project
      2yalc add eslint-plugin-custom
    4. Update your ESLint configuration in the host project to include the plugin and its rules in .eslintrc.js:

      1module.exports = {
      2  //Other configurations
      3  plugins: ["custom"],
      4  rules: {
      5    "custom/no-var": "error",
      6    "custom/strict-equality": "error",
      7    //Other rules
      8  },
      9};

      Here, we have excluded the prefix eslint-plugin-, while specifying the plugin name and used only the word custom. ESLint automatically recognizes plugins without the eslint-plugin- prefix when specified in the configuration file. Trimming off this prefix is a common practice to simplify the plugin name and make it more user-friendly in the context of the host project.

      We also namespaced the rule under their respective plugins. By doing so, we ensure that ESLint can distinguish between conflicting rule names if any, and apply the correct rules based on your configuration.

    5. Testing the custom plugin in the host project:

      • This can be done by running ESLint rules for a specific file:

        1npx eslint <file_path>
        2#or to apply and test fixes
        3npx eslint --fix <file_path>
      • You can use a glob pattern, such as **/*.js, to run ESLint on the entire project by specifying the file path pattern that matches the desired files to be linted:

        1npx eslint "./app/javascript/src/**/*.{js,jsx,json}"
      • You can integrate ESLint to VSCode by installing the ESLint extension. Once installed, to apply any changes made to ESLint configurations, simply restart the ESLint server, by opening the Command Palette (Ctrl+Shift+P or Cmd+Shift+P) and search for "ESLint: Restart ESLint Server". This will help you see red squiggly lines under the code containing the error. It is a good visual representation of our ESLint rule.

        Restart ESLint Server

      Do not forget to note down any edge cases you come across, so that you can refactor your rule's logic to cover those cases.

    6. Every time you make a change in your plugin, you can push those changes to your host projects by running the following command from your eslint-plugin's root directory:

      1yalc push
    7. Once you are done with testing your plugin locally, remove it from the package.json of your host project by running the following command from your host project's root directory:

      1yalc remove eslint-plugin-custom
  • Integrating published plugin package

    Once you publish the plugin into the remote registry, you can integrate it into your host projects.

    1. Add the custom plugin as a devDependency to your project using the command:

      1yarn add -D "eslint-plugin-custom"
    2. Configure ESLint to enable your custom rules. We have already added the necessary configurations in step 6, of integrating using yalc.

When dealing with multiple host projects, it becomes a repetitive task to include the same rules and error levels in each project. Any updates or adjustments to these rules would then need to be applied across all projects individually. To streamline this process, the recommended configuration lets us keep all those configs within the ESLint plugin itself. This way, we can easily maintain and modify the rules without the need for duplicating efforts in every project.

Imagine, we want to recommend using our current rules as warnings. In that case, we can add a recommended config in our eslint-plugin-custom's index.js:

1module.exports = {
2  rules: {
3    "no-var": require("./src/rules/no-var"),
4    "strict-equality": require("./src/rules/strict-equality"),
5  },
6  configs: {
7    recommended: {
8      rules: {
9        "custom/no-var": "warn",
10        "custom/strict-equality": "warn",
11      },
12    },
13  },
14};

In the host project, where you want to use your custom plugin, you can install the plugin and configure ESLint to extend the recommended configuration in .eslintrc.js:

1{
2  "extends": [
3    "eslint:recommended",
4    "plugin:custom/recommended"
5  ],
6  // Other ESLint configurations for your project.
7  "rules": {
8    // Other project-specific rules.
9  }
10}

General tips on using ESLint

In this section, we will explore some general tips and tricks that will empower you to navigate through false positives, troubleshoot common pitfalls, and handle ESLint warnings well.

Handling false positives

Sometimes, ESLint can be overzealous and flag code as incorrect even when it's acceptable. Alternatively, there may be instances where we intentionally choose to adopt a particular coding style and wish to prevent ESLint from throwing errors. Here are a couple of strategies to address these errors:

  1. Disabling ESLint rules:

    You can temporarily disable the rule responsible for the false alarm by adding a comment above the code, like this:

    1// eslint-disable-next-line <rule_name>

    Example:

    1// Reason for disabling the rule.
    2// eslint-disable-next-line no-console
    3console.log("All tests were executed");

    Do not forget to specify the reason why you have disabled that particular rule for that line, to avoid any future confusion.

  2. Altering configuration:

    ESLint provides an option to configure a rule as per your needs. Some rules accept additional options to customize its behavior. An example is, camelcase. It enforces the use of camel case for variable names. It accepts options to disable enforcing camel casing for specific cases. In a case where, you want to use a different naming convention, such as snake case, for object keys, you can set the properties option to never:

    1// .eslintrc
    2{
    3  "rules": {
    4    "camelcase": ["error", { "properties": "never" }]
    5  }
    6}

    This configuration tells ESLint to exclude properties (object keys) from the camel case requirement.

    We can accept options in our custom rules and access them inside our rules via context.options. Then, you can perform the necessary logic to handle such cases.

Common reasons for ESLint checks to crash

While using ESLint, you might encounter situations where ESLint checks crash or fail unexpectedly. Keep these in mind to avoid such crashes:

  1. If you update or switch ESLint configurations, make sure to run yarn install or npm install to install any missing dependencies.

  2. Ensure that your ESLint configuration has accurate parser options, like the language version and ECMAScript features, as they are crucial for ESLint to parse and analyze your code correctly.

Should we consider ESLint warnings?

As we've seen in previous sections, ESLint allows us not only to report errors but also to show warnings. When creating a rule, it may not always be possible to cover every edge case and eliminate all false positives. In such situations, we can configure these rules as warnings instead. Additionally, there are cases where we don't want to enforce a specific coding style but rather suggest a more optimized approach to the developer. In such scenarios as well, we avoid throwing errors.

While ESLint warnings don't necessarily require disabling through comments, it's recommended to review and address them whenever feasible. This practice improves code quality, helps prevent future errors, and enhances the overall robustness of the codebase. However, if you find that the suggestion does not apply to your specific situation, you have the flexibility to disregard it or disable it by including a comment.

Conclusion

Throughout this blog, we explored various aspects of ESLint, including understanding its purpose and benefits, configuring rules on a project, writing custom rules and plugins, and testing them effectively. We also discussed general tips for using ESLint, such as handling false positive errors, dealing with crashes, and considering warnings.

Remember to periodically review and update your ESLint configurations as your project evolves, and stay up-to-date with the latest ESLint releases and rule updates to take advantage of new features and improvements. To know more about functionalities of ESLint, you can refer the ESLint documentation.

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.