Customizing ESLint: Utilizing Custom Rules
Tags: javascript, eslint, webdev, react
Published: 2023-12-04 07:30:03

Recently, while working on a project, I found myself repeatedly stumbling upon a small, yet annoying mistake. I kept using `./..` to navigate back to the previous page, which was **unnecessary** since we could achieve the same result without having `./` at the beginning of the relative path. I kept making this mistake again and again until my team and I decided to take proactive measures. We realized that leveraging custom Eslint rules could effectively enforce the desired behavior, freeing us from worrying about such minute details.

In this article, we will explore our approach to resolving the issue and the challenges we faced along the way. For detailed instructions on creating your own customized Eslint rules or plugins, please refer to the [Eslint docs](https://eslint.org/docs/latest/extend/plugins).

**What did we resolve?**

For this particular project, we are using `react` and `react-router-dom`. We will be utilizing the `useNavigate()` hook from `react-router-dom` for navigation.

```js
import { useNavigate } from 'react-router-dom';

function Component() {
  const navigate = useNavigate();
  const handler = () => {
    navigate('./..');
  };

  return (
    <button type="button" onClick={handler}>Back</button>
  );
}
```

The above code snippet describes a simple react component that renders a back button. When the back button is clicked the `navigate()` function will programmatically navigate the user back to the previous path.

The correct usage of the `navigate()` function typically involves passing a string path as a parameter, such as `navigate('path')`. So what we wanted was to go through the code base and refactor all the navigation paths such as `navigate('./path')` to `navigate('path')`. Also, we wanted Eslint to automatically correct the navigation path errors while linting.

**How did we do it?**

So, our objective is to trigger the Eslint rule when the `navigate()` function is called. To achieve this, the first step is to write a basic Eslint rule. This rule exports a `meta` object that describes the rule and a `create` function. Please refer to Eslint rule docs for a better understanding of [Eslint's custom rule](https://eslint.org/docs/latest/extend/custom-rules) structure. Also, you can use tools like https://astexplorer.net/ for testing your custom rules while working on them or going down this article.

```js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description:
        "Enforce the correct way of using navigate('path'), shows error if navigate('./path') is used instead."
    },
    fixable: 'code',
    hasSuggestions: true
  },
  create: context => {
    return {};
  }
};
```

The `create` function returns a simple object with methods as properties. Each property is used for representing an abstract syntax tree (AST) node, it can be a function declaration, variable assignment, or function call. ESLint calls each returned property to "visit" nodes while traversing the AST of the JavaScript code.

In our case, the method we will be exposing is `CallExpression`. The `CallExpression` method will be called when Eslint visits a 'function call' expression.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qqitw7bqmqeu2a0gftyy.png)

Since the `CallExpression` will be called for all 'function call' expressions we have to tone it to our needs. First, we check if the function is named 'navigate'. Then we check how many arguments the function has. In this case, it should only have one argument of type 'string'.

```js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description:
        "Enforce the correct way of using navigate('path'), shows error if navigate('./path') is used instead."
    },
    fixable: 'code',
    hasSuggestions: true
  },
  create: context => {
    return {
      CallExpression(node) {
        const callee = node.callee;
        const args = node.arguments;

        if (
          callee.type === 'Identifier' &&
          callee.name === 'navigate' &&
          args.length === 1 &&
          args[0].type === 'Literal'
        ) {
          const argument = args[0].value;

          if (typeof argument === 'string' && argument.startsWith('./')) {
            const suggestion = argument.replace('./', '');
            context.report({
              node,
              message:
                'Incorrect usage "navigate({{ argumentText }})", please use "navigate({{ suggestionText }})" instead.',
              data: {
                argument,
                suggestion
              }
            });
          }
        }
      }
    };
  }
};
```

All the relevant information about the 'function call' can be obtained from the `CallExpression`‘s argument `node`.

If the mentioned conditions are satisfied, the code will verify if the string argument starts with `./` characters. If it does, the code will remove those characters from the `navigate()` function argument and 'report' the correct usage to Eslint.

**What do you mean by 'report'?**

The `create` function has a `context` object as an argument. This `context` object contains information that is relevant to the context of the rule such as the `report` method. The `report` method is the main method that publishes a warning or an error to Eslint. We can use this method to publish an error message or a suggestion when the above conditions are met. Also, if you want Eslint to automatically enforce and correct your mistakes on the fly, you can use the can pass a `fix` function as an argument to the `report` method.

**Just when we thought we were done!**

From our (Javascript) point of view, the argument passed to the `navigate()` function is of type 'string' but according to Eslint it can be either of type 'Literal' or 'TemplateLiteral'. So what is the difference between these two types?

[Template literals](https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html) are string literals delimited with backtick (`) characters.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t8yppscpkfktk7tzkdnd.png)

Whereas 'Literal' is just plain old string type delimited with (') or (") characters.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ewha2roszk3vchiq58iw.png)

If you check the previous code snippet you can quickly find out there is an issue with the code. The issue is the way we get the argument’s value of type 'TemplateLiteral' is different from 'Literal'. If you pass in an argument of type 'TemplateLiteral' to the `navigate()` function the Eslint rule will have a hard time identifying the issue.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fr8ua60uef70z8hgkbcf.png)

From the above image, we can see the Eslint rule wasn’t able to identify the argument as a 'string' literal and suggest a correction. We know the argument will resolve to string but Eslint will identify it as 'TemplateLiteral'. In order to resolve this issue, we had to add an additional `if-else` clause to identify the argument's type and extract the argument's value properly.

**The Fix.**

The `context` object also has another crucial property, the `sourceCode` object. The `sourceCode` as the name implies is the source that was passed to ESLint. Using this object we can directly interact with the AST and retrieve what is required. In our case, we want to get the `navigate()` function's argument as a text rather than what Eslint provided.

`
```js
let argument;
const type = args[0].type;
const argumentText = sourceCode.getText(args[0]);

if (type === 'Literal') {
  argument = args[0].value;
} else if (type === 'TemplateLiteral') {
  argument = argumentText.replace('`', '');
}
```
`

The above code snippet checks, if the type of the argument is a 'string' Literal get the value from the `args` variable but if it's a type 'TemplateLiteral' it will try to get the argument as a text directly from the source code. Since the direct text value will also include backticks (`) we have to remove them.

**So that is it, a couple of `if-else` clauses there the Eslint rule is ready.**

```js
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description:
        "Enforce the correct way of using navigate('path'), shows error if navigate('./path') is used instead."
    },
    fixable: 'code',
    hasSuggestions: true
  },
  create: context => {
    const sourceCode = context.sourceCode;

    return {
      CallExpression(node) {
        const callee = node.callee;
        const args = node.arguments;

        if (
          callee.type === 'Identifier' &&
          callee.name === 'navigate' &&
          args.length === 1
        ) {
          let argument;
          const type = args[0].type;
          const argumentText = sourceCode.getText(args[0]);

          if (type === 'Literal') {
            argument = args[0].value;
          } else if (type === 'TemplateLiteral') {
            argument = argumentText.replace('`', '');
          }

          if (typeof argument === 'string' && argument.startsWith('./')) {
            const suggestionText = argumentText.replace('./', '');
            context.report({
              node,
              message:
                'Incorrect usage "navigate({{ argumentText }})", please use "navigate({{ suggestionText }})" instead.',
              data: {
                argumentText,
                suggestionText
              },
              fix(fixer) {
                return fixer.replaceText(args[0], suggestionText);
              }
            });
          }
        }
      }
    };
  }
};
```

**In conclusion**

Summing it up, you can utilize Eslint custom rules in different scenarios to improve your productivity and code efficiencies. Also, understanding how Eslint works can help to easily figure out a solution when you are faced with those pesky Eslint configuration issues. Hope this article inspired you to write your own custom rules :)