The goal of this series of articles is to explain how to share our ESLint configuration as an external dependency to automate the Z1 Digital Studio front-end team's code standards.
When starting to investigate how to do this, I discovered that ESLint is in the process of releasing a new configuration system called flat config. This system is already functional, has support in CLI, and official documentation available from version 8.23.0. It replaces eslintrc (from now on the legacy or traditional system), which will lose support from version 9. In this link you can read about the implementation process.
Flat config proposes drastic changes in the way we configure ESLint in projects. Therefore, it is worth making a small digression to learn about the new configuration before jumping into creating our external dependency. This way we can lead its adoption and avoid refactoring when the change is effective.
This article assumes that you have used ESLint before, although you may not have gone into detail about how it works or all that it can offer.
The plan for this series of articles is as follows:
Part 1. Mastering *ESLint. First, we will learn everything we need to know about the legacy system to get the most out of the migration process. So, we can use ESLint with confidence and control.
Part 2. Migrating to flat config. We discover the essential changes proposed by flat config and migrate our case study to the new system.
Part 3. Creating an ESLint shareable config. We go deeper into shareable configs and the ESLint dependency ecosystem. We incorporate other static analysis tools. We start configuring our repository as an NPM dependency.
Part 4. Improving the experience with additional tools. We add version and dependency management. We create a README to document and facilitate the use of our dependency. We explore creating a CLI to complement it.
ESLint is a static code analysis tool. Unlike dynamic analysis tools, such as testing, which needs to execute the code to give us a result, ESLint is capable of analyzing our code without executing it. This way, it helps us to maintain and improve the quality of the code we write as we write it.
“ESLint automates our opinions based on rules, and warns us when one of these rules is breached.”
It is the most popular tool in its category, which includes others like Prettier, StyleLint, CommitLint... or the type checker of Typescript. We configure these tools at the beginning of the project, and they continuously assist us during its development. Ideally, we should run them at different stages of the process (in the IDE, when committing, in our continuous integration pipeline...), to ensure that we meet the quality standards we have established.
And how does ESLint help us create and maintain quality standards? First of all, it doesn't make decisions for us, but rather leaves it in our hands, in the hands of the team, to agree on what will define code quality. It automates our opinions based on rules and warns us when one of these rules is broken.
All this is expressed in one or more configuration files, where we declare the rules that will apply to the project. We will usually also rely on an ESLint extension for our IDE to get immediate feedback. Without this extension, we would only have the ESLint CLI to rely on for code review.
The value we get from ESLint depends largely on the effort we invest in understanding it. A lot of the time it is adopted by inertia, blindly transferring the same setting from one project to another, with no control over what those rules say about our project design.
Even in the worst cases, ESLint can become an enemy that shouts at us, and we don't understand why: "if my code works, what is ESLint complaining about now?". Then it behaves like an overload of configurations that we don't know how to handle, and that will make us hate the blend of red and yellow wavy lines that roam freely through the project files.
But when we use it the right way, ESLint is a superpower. It helps us maintain consistency throughout the codebase during the entire life of the app, improving its design and maintainability.
“If we find ourselves repeatedly correcting or commenting on a recurring error with the team, there is probably an ESLint rule we can add to automate the solution. The best conventions are the ones that are automated.”
Writing software is a team activity. As a team, we agree on good practices and conventions that allow us to work together and move forward quickly and safely. But any rule, no matter how good it is, is useless if the team is not able to apply it consistently.
This is where ESLint shines, because it allows you to align the team around these conventions, which are documented in the configuration file, and at the same time frees them from having to recode and apply them every time.
These conventions can include syntax and naming preferences, style conventions, prevention of logical or syntax errors, detection of obsolete code usage, use or avoidance of certain patterns, among others.
Whether we find ourselves repeatedly correcting or commenting on a recurring error with the team, there is probably an ESLint rule we can add to automate the solution. The best conventions are the ones that are automated.
Before starting with the case study, we review the main properties of the ESLint configuration object:
ESLint rules (rules) are designed to be completely independent of each other, activated and deactivated individually. ESLint is a tool with which to automatically impose our views on the code, so there is no rule that we cannot deactivate. Everything is subject to opinion and will depend on our needs. Rules accept three possible severity levels: "error", "warn" and "off", and can accept an array to configure some options more precisely. Many of them have autofix capability, to automatically correct the error.
The overrides property is very important in the legacy system, and will also play a prominent role in the flat config. It is an array that accepts objects in which we define specific configurations for subsets of files. To define each subset, we use the files and excludeFiles properties. These properties take globs expressions as a value relative to the directory where the configuration file is located.
Overrides is an alternative and more understandable functionality compared to the cascading design, very characteristic of ESLint, which we will delve into in part 2 of this series.
There is something that is quite strange in ESLint, and that has caused me confusion more than once, because we have dependencies called eslint-plugin-foo and others called eslint-config-foo. And because in some occasions it is indicated that we have to use them with extends, and others with plugins.
As we have said, ESLint is a modular and configurable system. We can install additional rules to configure our perfect use case. These rules come packaged in NPM dependencies with the name eslint-plugin-[my-plugin]. To use them, we install them and pass the name to the plugin array: plugins: ["my-plugin"] (it is not necessary to use the prefix eslint-plugin-).
But this does not automatically activate our rules. When we pass the value to the plugins array, we are simply making them available to the system for use. Then we can activate the ones we want in the rules property:
This is where shareable configs (hereafter configs) come into play. To save us the tedious work of having to activate rules one by one, there are other NPM dependencies with the name eslint-config-[my-config] that directly activate a set of predefined rules when included in the extends array: extends: ["my-config"] (it is not necessary to use the prefix eslint-config-).
Configs can use one or several plugins underneath, can be extended from other configs and add for us, in addition to rules, any other configuration necessary for their proper functioning.
Finally, it is common for plugins that are shared as dependencies to also bring a set of configs with them that the authors have considered useful and that we can use in extends. For example, eslint-plugin-react includes as configs recommended, typescript, jsx-runtime, etc.
This can be confusing, as both the plugin itself and a set of configs are exported in the same plugin dependency. But it is highly convenient because it allows us to both extend from a predefined configuration and apply individual rules.
To use an imported config from a plugin, we follow the syntax: plugin:[name-plugin]/[name-config]
- Configs can contain anything that can be added to an ESLint configuration file, they come packaged as eslint-config-<my-config> , and are passed to the extends property. They are the way we can share and reuse "ready-to-consume" configurations and save us the work of creating them ourselves.
- Plugins add new rules to the system. They come packaged as eslint-plugin-<my-plugin> and are passed to the plugins property, so that rules can be activated individually in rules. They can also export configs to activate pre-defined sets of those rules.
In addition to rules, overrides, extends and plugins, the ESLint settings includes other properties, such as env, settings, parser, parserOptions, etc., which are essential to the functionality of ESLint. For example, defining the behavior of plugins, making ESLint able to interpret different syntaxes, recognizing environment variables, etc. We will see the most common ones below, in our practical case. We can pay attention to their configuration, because in the flat config (part 2) they will be transformed and reorganized.
Let's turn to action! In this section, we are going to incrementally create a real, although simplified for example purposes, ESLint configuration that we use for production projects with the following stack:
One of the hallmarks of ESLint, responsible for much of its success, is its extensibility. The ESLint ecosystem is made up of a wide variety of plugins and configurations available as NPM packages that we can import to establish our use case.
Sometimes the number of dependencies that we have to install to configure a project can be overwhelming. But the reward is that we can install exactly what we need:
Finally, we need to configureeslint:recommended contains a series of rules that the ESLint team, after analyzing many projects, considers useful in most cases. So the first thing we do is include these rules in our configuration. In the traditional system, they are included within ESLint, so nothing needs to be installed.
Prettier is a formatter, ESLint is a linter. Formatters are faster and less "intelligent" than linters, because they do not evaluate the code logic. They are responsible for rewriting it following purely visual formatting rules (tabs, spaces, dots and commas, line lengths...). While linters understand the logic and syntax, and give us indications according to each of the activated rules.
When we use Prettier and ESLint together, since Eslint contains formatting rules, we need to install something like eslint-config-prettier, to deactivate those rules and indicate to ESLint that Prettier will be in charge of formatting.
The eslint-plugin-prettier and related plugins are not recommended in the vast majority of cases. They make Prettier behave like a linter rule, which is much slower. There is no need to do this when we have Prettier configured as a stand-alone tool.
When using Prettier and ESLint in the same project, it is important that we allow each tool to perform the task it does best.
Plugins need to know the React version, because their performance may depend on it, so we use settings to tell ESLint to look in the package.json.
We use the recommended settings in extends.In addition, we add to extends plugin:react/jsx-runtime, another configuration that helps us disable the rules that require us to import React at the beginning of each file, which is not necessary from React 17.
All the configurations we have extended include parsing options so that ESLint can interpret jsx. Even so, we add it explicitly to the file for greater clarity.
For ESLint to be able to understand Typescript files, we need to install @typescript-eslint, which contains a parser and a bunch of recommended rules for working with Typescript. In this case, we need to use overrides and create a block where we pass globs to capture files with extensions .ts and .tsx.
We will extend the recommended configuration, plugin:@typescript-eslint/recommended, but also a second configuration, plugin:@typescript-eslint/recommended-requiring-type-checking, which make ESLint much more powerful by using type information to detect errors. To make it work, we have to provide ESLint with type information. We do this with project: true, which tells ESLint to look for the nearest tsconfig.json.
We're also going to extend plugin:@typescript-eslint/eslint-recommended. What it does is disable eslint:recommended rules that are already controlled by Typescript, to avoid duplication.
We install the official Storybook plugin for ESLint, which contains best practice rules for handling stories and the configuration directory .storybook : eslint-plugin-storybook. By default, the rules of this plugin only apply to files matching the patterns: *.stories.* (recommended) or *.story.*. So it is very important that the names of our story files follow this convention, or the plugin will not take effect.
In ESLint, files that start with a dot are not analyzed by default. So we also need to add the .storybook directory to the list of files we want to analyze. We use a negation pattern in ignorePatterns so that ESLint is able to find this directory:
We use eslint-plugin-jsx-a11y to help us detect potential accessibility errors in our React components. We simply extend the recommended configuration.
With eslint-plugin-import we can prevent a number of errors related to module import and export. We will need to install eslint-import-resolver-typescript to have Typescript support.
We activate some specific rules for this plugin:
- At a stylistic level, import/sort will sort and group imports at the beginning of our files automatically (it is a rule with autofix), making it much easier to understand the imports and avoid maintaining them manually.
- We activate import/no-extraneous-dependencies to throw an error if we import dependencies that are not defined in package.json.
- We enable import/no-default-export because we prefer to use named exports. In some cases, such as in stories, we need to allow default exports, so we will enable a block in overrides to handle this type of exception.
For our ESLint configuration to be able to interpret our testing files, we also need to make some additions, which will depend on the tools we are using.
- Jest. To use Jest we need to activate the global variable jest in env, so that ESLint can recognize it. We also need to allow our mocks to contain default exports.
- React Testing Library. We install two plugins: eslint-plugin-testing-library and eslint-plugin-jest-dom. We use them only for our testing files:
- Cypress. We will install another plugin that will only analyze the files in the /cypress folder. In the parserOptions of the Typescript block we will modify project to include the Cypress type settings:
Et voila 🎉 ! Here we have our final configuration file, integrating all the parts we mentioned and adding some details, such as the activation of a handful of individual rules.
Now that we have the configuration for the project ready, we can attend to other aspects that help us with the ESLint user experience.
First, we can configure Visual Studio Code in the context of our project, so that all team members work in an environment with the same behavior. In the project root, we create a .vscode directory with two files:
Finally, we need to configure a couple of ESLint scripts in our package.json, in order to integrate it into the different phases of the development process: when committing, in the CI pipeline, etc.
These are some useful options that we can pass to the command:
In part 2 of this guide, we will see how to migrate our configuration to the new ESLint system, the flat config.
Originally published in Spanish on María Simó’s blog.
Join us for a monthly dose of digital product design, juicy inspiration, and useful tools.