Upgrading to the new ESLint config
At the end of writing the From zero to hello (world) post I noticed that the jsx-eslint plugin mentioned that ESLint had simplified their configuration file, but as I didn't want to make the post even longer I chose to focus on how to migrate to that new format separately.
Not only does it make it easier to read the previous post, but also makes it easier to refer to these steps.
I hope it will help all the people tasked with maintaining existing projects, and also using code that generators and templates for popular libraries and projects keep creating, a while after the format was introduced... which is how I ended up introducing the old format in my sample project. Ooops!
Where you might be now
- The configuration for ESLint is spread across multiple files in your project (
.eslintrc
in the project folder,~/.eslintrc
in your home folder, other.eslintrc
formats such asjs
,json
oryml
,eslintConfig
key inpackage.json
), and as a consequence you're full of uncertainty as to what is loaded when, and what overrides or not (the 'cascade' problem). - There are configuration options whose domain overlaps (e.g.
parserOptions
influencing how the code is parsed given the expected interpreter, and theenv
option to define available globals given the expected interpreter) - Related: there are esoteric, not entirely obvious configuration keys (for example
"browser": true
); you need to consult the documentation to really understand what do they actually cause to happen. - Complicated configuration file in general, as
extends
,excludes
andoverrides
nested and mixed together make it hard to figure out what applies to what.
Where we want to be
- No or reduced uncertainty: only one file
eslint.config.js
needed. - Write less config (reasonable defaults).
Practical example: updating from zero to hello
There's nothing like an actual example to demonstrate if an idea works or not. So the goal of this post is to describe how I migrate the zero to hello (world) project to use an updated ESLint config.
The bad thing about this project is that it is very simple so there's very little to change. It has Vite, React, JSX, ESLint. Quite minimalist for nowadays standards.
The good thing is that it is simple enough that it can give you a good idea of how to apply changes iteratively until you've migrated your configuration to the new format, without being too long of a process. And even then, it already has some tricky bits that took me a while to grasp, so don't think this is too trivial either.
With that in mind, said process is essentially a while loop:
while(issue = getNextIssue()) {
fixTheIssue();
}
Where we are now and what we want instead
Hint: browse the repo at the 5a7d74a3fd commit to see exactly what we start with.
- ESLint version 8.49.0
- An
.eslintrc.cjs
file contains the configuration, but we want to useeslint.config.js
instead parserOptions
andenv
keys in the configuration are complicated: we will simplify this (code)- ESLint cannot derive all the information from the config file. We need to call it with arguments stated in
package.json
:eslint . --ext js,jsx
(code) — but we want ESLint to find all it needs in the config file, and keeppackage.json
commands very simple.
Addressing the issues
Moving to eslint.config.js
For compatibility reasons ESLint >= 8.23.0
will only start parsing the config file the new way if it finds an eslint.config.js
file in the project directory, so it seems like this is the obvious first step (as discussed above, we're using 8.49 so we're good to go).
There are two options to do this:
- Create a new
eslint.config.js
file with minimum contents and progressively add changes until we obtain the desired result, then delete the former.eslintrc.cjs
file. - Rename the existing config file using git, and start editing until we obtain the desired result.
It is really helpful when you inherit a project to be able to trace when and why were changes done to files. If I choose option 1, it is going to make it look like the new config file emerged from nowhere, and the history with the previous file will somehow be not obvious as it will get deleted and not appear as you check out the code, which can make it hard to debug issues months or years later.
Since I have been biten by the lack of history in the past, I choose option 2: rename and edit. I am also going to create a separate branch so I can always compare with where I came from, and isolate the changes. We could say that updating the configuration file format is a feature, so it makes sense to treat it as such and work in a feature branch.
Besides, as we'll see once we start running the linter with the new config file in place, ESLint have added a lot of helpful error messages to help you move away from the old format, so starting with the existing contents is actually a good way to get ESLint to tell you exactly what is wrong.
So:
$ git checkout main
Already on 'main'
Your branch is up to date with 'origin/main'.
$ git checkout -b new-eslint-config
Switched to a new branch 'new-eslint-config'
$ git mv .eslintrc.cjs eslint.config.js
$ git status
On branch new-eslint-config
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
renamed: .eslintrc.cjs -> eslint.config.js
The output confirms we're on the good track, we're in a new branch and the right file has been renamed.
Although now, when I run the linter, I get an error:
$ npm run lint
> zero-to-hello@1.0.0 lint
> eslint . --ext js,jsx
Invalid option '--ext' - perhaps you meant '-c'?
That is all OK and to be expected; we need to change the config file to use the files
pattern rather than leaving it to a separate configuration file to specify.
Since we need to do changes in another file and things can get muddy if we address multiple things in the same commit, I will do an inaugural commit now and start changing the package.json
file to make the lint process pass again in a separate step. You could argue that this 'would break the build', but this is also what feature branches are for: to be able to progress incrementally—you can always squash the changes into one single commit before merging to main.
So off we go:
git commit -m 'rename ESLint config file to new default name'
[new-eslint-config 169ae0a] rename ESLint config file to new default name
1 file changed, 0 insertions(+), 0 deletions(-)
rename .eslintrc.cjs => eslint.config.js (100%)
Add files
pattern to the config
The next step is to make the lint
command work again.
Add a files
pattern to eslint.config.js
:
files: ["src/**/*.js", "src/**/*.jsx"],
We also need to remove the invalid option from package.json
, as what to lint is by default .js
, .cjs
and .mjs
files, and if you want to change this, you need to use a configuration (which is what we've done to add .jsx
files).
The lint
command should look like this now:
"lint": "eslint ."
We run npm run lint
again and...
$ npm run lint
> zero-to-hello@1.0.0 lint
> eslint .
Oops! Something went wrong! :(
ESLint: 8.49.0
A config object is using the "root" key, which is not supported in flat config system.
Flat configs always act as if they are the root config file, so this key can be safely removed.
ESLint nicely tells us two helpful facts:
- It confirms it is using the flat config! i.e. the new version! Yay!
- We are using an outdated
root
option and we should remove it.
So, let's just do it and remove root: true,
from the ESLint config file. Then we run the lint
command again:
$ npm run lint
> zero-to-hello@1.0.0 lint
> eslint .
Oops! Something went wrong! :(
ESLint: 8.49.0
A config object is using the "env" key, which is not supported in flat config system.
Flat config uses "languageOptions.globals" to define global variables for your files.
Please see the following page for information on how to convert your config object into the correct format:
https://eslint.org/docs/latest/use/configure/migration-guide#configuring-language-options
This is another helpful piece of information, but since the changes I made to files
and root
are related and this last change would not be, I am going to do a commit before doing more changes:
$ git add eslint.config.js package.json
$ git commit -m 'Specify files to lint in ESLint config file'
[new-eslint-config 45bd8a5] Specify files to lint in ESLint config file
2 files changed, 2 insertions(+), 2 deletions(-)
Turn the ECMAScript module mode on
Before we deal with the env
error, we need to change the exporting mechanism for the file, as it's using a CommonJS pattern but we want to use ECMAScript throughout. Otherwise we'll get tricky errors to resolve later.
So to make the file be up-to-date, we go from module.exports = ...
to export default [{...}]
instead in eslint.config.js
(notice we now return an array of configurations, not just an object/module).
I also added "type": "module"
to package.json
so that node interprets the file as an ECMAScript module, not a CommonJS module, thus avoiding the SyntaxError: Unexpected token 'export'
error that you'd get in upcoming steps otherwise.
Consolidate env and parserOptions into languageOptions
Back to the env
error: the current option value is env: { browser: true, es2020: true },
, i.e. we expect this to run in a browser and using ECMAScript 2020. ESLint advises using languageOptions.globals
instead, so we can start by creating the key in the config file:
languageOptions: { },
Previously, we used browser: true
in the env
key because we wanted the usual browser globals to be assumed to be there when parsing. The new equivalent would be using the globals
key in languageOptions
, and telling ESLint which globals we expect to be there. Now, rather than using a built-in list from ESLint, they advise using an npm package that specifies those, globals, and which ESLint already makes available:
We can use it like so:
import globals from "globals";
// [...]
languageOptions: {
globals: {
...globals.browser
}
},
// [...]
Notice three things:
- We're importing the
globals
data first - the
...
spread operator in this context expands whichever keys are inglobals.browser
before and adds them as keys into theglobals
key oflanguageOptions
. Very handy! - The idea behind this is that by externalising this list, it can be kept up to date separately, and also, it allows you to be VERY specific as to what you want to check. I could imagine this being very handy if you're embedding a JS engine somewhere and want to make sure you use the right globals.
Similarly, es2020: true
was used to let the parser know that we expected to use a modern flavour of ECMAScript. But this now rightly goes into a parserOptions
section, rather than an "environment", and the end result will be like this:
languageOptions: {
globals: {
...globals.browser
},
parserOptions: {
ecmaVersion: 2020
}
},
But... notice that there's a top global key called parserOptions
in the file already, and it contradicts what we just wrote:
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
We ask for the latest ECMAScript (which would be 2023 by the time I write this) but we also specify 2020 at the same time. This makes no sense, so I will unify to just 2020
for specificity.
Also, we should move the sourceType
option to the new nested parserOptions
.
The final result is this:
languageOptions: {
globals: {
...globals.browser
},
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
}
},
And after deleting the old env
and parserOptions
, I run the linter again:
> zero-to-hello@1.0.0 lint
> eslint .
Oops! Something went wrong! :(
ESLint: 8.49.0
A config object is using the "ignorePatterns" key, which is not supported in flat config system.
Flat config uses "ignores" to specify files to ignore.
Please see the following page for information on how to convert your config object into the correct format:
https://eslint.org/docs/latest/use/configure/migration-guide#ignoring-files
This new error has nothing to do with what we just addressed, so I'll commit and move on to fixing that.
Move from ignorePatterns
to ignores
This was by far the trickiest part of the migration, so pay attention!
I initially thought that since we were describing the files we wanted ESLint to analyse with this:
files: ["src/**/*.js", "src/**/*.jsx"],
... that I would not need to add any ignores
configuration. After all, I am telling it to only look at JS and JSX files in my src
folder, right?
WRONGGGG!
In the new ESLint model, what the files
entry above means is: apply the rules from this configuration to this set of files.
Or in other words, all the language options and settings that we specify in an entry of the array of configurations we are returning in the config file are applied to the files that are described by the value of the files
pattern(s), if it is present in that configuration.
If no files
is present, then the settings of that configuration are applied to all the files that ESLint has marked as candidates for linting by the time that configuration gets processed.
Yes, that's right. Even if you specify a very restrictive files
value in a configuration, it does not exclude files from the list of candidates.
And you would think: "OK, weird, but fine, then I will use the ignores
key to restrict this list".
This would bring you almost there, except things won't work the way you'd expect them to work, because...
- If you add
ignores
to a configuration withfiles
, it has no effect if your selection is trying to ignore things, because the ignoring is applied to the files that configuration applies to, which are normally not a superset of what you're trying to ignore. So it is effectively ignored. (It's like giving you a bag with only white balls and telling you to ignore all the blue balls: there is no discernible outcome because there are no blue balls in the bag). - If you add a new configuration with an
ignores
entry only, it DOES cause ESLint to globally ignore files when linting.
You might need to read the docs on excluding and the docs on using files and ignores at once several times until things start to fall into place.
Or let's consider what ESLint does when initialising with the new flat config, using this pseudopseudocode:
the default configuration starts with a wide net: find all
.js
,.cjs
and.mjs
files but ignore patterns such as.git
andnode_modules
directoriesYour config file can return
n
configurations:export default [ { /* configuration 1 */ }, { /* configuration 2 */ }, // ... { /* configuration n */ } ]
Each configuration can...
- use
files
to- ADD A SUBSET of files/folders to be considered, _or_
- ADD SPECIFICITY by making rules for files in a given subset MORE SPECIFIC
- or use
ignores
to- EXCLUDE A GLOBAL SUBSET (if there is no
files
entry in that configuration) _or_ - EXCLUDE A LOCAL SUBSET (remove entries that would match from the pattern specified by a
files
entry in this configuration)
- EXCLUDE A GLOBAL SUBSET (if there is no
- use
So you could have a config file like this where some generic rules are specified first for JS and JSX (adds JSX files to the process and applies rules to both JS and JSX), then some for just JSX files (adds specificity to JSX files), and finally we exclude files we don't want to even lint.
export default [
{
/* configuration 1 for all JS and JSX files */
files: ["src/**/*.js", "src/**/*.jsx"],
// rules...
},
{ /* configuration 2 for JSX files */
files: ["src/**/*.jsx"],
// ... some rules that are different
},
{ /* configuration 3 to EXCLUDE files */
excludes: ["src/**/*.jsx"],
// ... some rules that are different
}
]
Or another way to think of it would be that the config parsing process starts looking at a default subset of a tree, and follows all their branches until the leaves recursively. Each configuration can tell you to add branches or leaves to the processing, or to look at certain branches and judge them and their children in a given way, or tell you to discard some branches or leaves altogether, either globally or at "subset" level.
To bring this discussion back to our project, the config file currently has this entry:
ignorePatterns: ['build', '.eslintrc.cjs'],
As we have seen, adding an ignores
entry to the single configuration object entry in the array that we're exporting currently, like this:
ignores: ['build/**', '**/.eslintrc.cjs'],
would just not cause the outcome we want due to the way the flat config is constructed. What it would do is to not apply the environment and parser options we've already described to the entries in ignores
!
What we must do is add a new configuration object after the current one, to explicitly discard these patterns from the list of files to lint:
{
ignores: ['build/**', '**/.eslintrc.cjs']
}
Although these entries are not right, as they're just hard migrated from the previous values.
For starters, the ESLint config file name has changed. We also might want to ignore other config files, and the previous build
pattern was excluding everything rather than just .js
files. Maybe we want to let it find .jsx
files and tell us about that, as we should not have .jsx
files in the build
directory!
So we'll add this entry:
{
ignores: [
"build/**/*.js",
"**/**/*.config.js"
]
}
Once that's added as another entry to the array, and the old ignorePatterns
line removed, running the linter gives us a different error:
> zero-to-hello@1.0.0 lint
> eslint .
Oops! Something went wrong! :(
ESLint: 8.49.0
A config object is using the "extends" key, which is not supported in flat config system.
Instead of "extends", you can include config objects that you'd like to extend from directly in the flat config array.
Please see the following page for more information:
https://eslint.org/docs/latest/use/configure/migration-guide#predefined-and-shareable-configs
So far, so good. Time for committing and fixing the extends
issue.
A final note before we move on from the wonderfully confusing world of ignores
: there is a use case for using both files
and ignores
together, and it is when you want to find files and exclude some of the files in that subset using ignores so that you can apply options to the subset of the subset. For example maybe you want to apply some rules to all the .js
files, but not to the .config.js
files - this would be a good use case for both options used together in a configuration. But we don't need it in this project, so if you want to know more, read the docs!
Stop using extends
We're indeed using extends
to load settings from various plugins and have them included in the final configuration:
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime'
],
This causes ESLint to try to load plugins sometimes, or require
internals such as its recommended settings. It's all a bit 'implicit' and requires you to know where things are coming from and installing the necessary plugins if you want to use them. It also makes things hard for static code analysis, of course, as you can't resolve dependencies unless you know what ESLint will do when running.
The new approach is to explicitly add each of these configurations to the array of configurations that the config file exports, rather than requesting this loading indirectly via the extends
key, and import plugins or any other required stuff explicitly (ESLint's migration examples are really good at explaining this).
In our case, we first need to install ESLint's rule configs before we can use them:
npm install --save-dev @eslint/js
Then we import it into the config file, and include it in the exported value:
import js from "@eslint/js";
export default [
js.configs.recommended,
// ...
Notice how I added the new array entry to the top of the export. So it gets included first, and we can override it if we want to, by adding further configurations to the array.
We follow a similar process to include the react/recommended
and react/jsx-runtime
configurations, with the beginning of the config file looking like this now:
import globals from "globals";
import js from "@eslint/js";
import reactConfig from "eslint-plugin-react";
export default [
js.configs.recommended,
reactConfig.configs.recommended,
reactConfig.configs["jsx-runtime"],
{
// *our* config starts here, as the 4th array entry
We can delete the old extends
key as well.
As you can imagine, running the linter now gives a new error which has nothing to do with this change, so we commit and move on.
Plugin keys should be objects, not arrays of strings
The new error:
ESLint: 8.49.0
A config object has a "plugins" key defined as an array of strings.
Flat config requires "plugins" to be an object in this form:
{
plugins: {
react: pluginObject
}
}
Please see the following page for information on how to convert your config object into the correct format:
https://eslint.org/docs/latest/use/configure/migration-guide#importing-plugins-and-custom-parsers
If you're using a shareable config that you cannot rewrite in flat config format, then use the compatibility utility:
https://eslint.org/docs/latest/use/configure/migration-guide#using-eslintrc-configs-in-flat-config
I thought this would be an easy fix, very similar to above, which requires us to explicitly import and use a module rather than having ESLint load it, and that the offending code was just the plugins: ['react-refresh'],
line.
So I explicitly imported the plugin first
import reactRefreshPlugin from "eslint-plugin-react-refresh";
then updated the plugins
and rules
entries so they would use the new syntax:
plugins: {
'react-refresh': reactRefreshPlugin
},
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
... but I kept getting the error when running the linter.
Through much re-reading the ESLint React plugin documentation, changing and testing, I realised that I also needed to tell the parser that we wanted the source to be treated as a module:
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module' // <-- new
}
At this point I realised that I also needed to import the React configs differently, as the export is a plugin, not a configuration, and things were not being parsed correctly and I kept getting errors. So I needed to change this line I added in the previous step:
import reactConfig from "eslint-plugin-react";
... into these two lines:
import reactRecommendedConfig from "eslint-plugin-react/configs/recommended.js"
import reactJSXConfig from "eslint-plugin-react/configs/jsx-runtime.js"
And then reference them in the exported array, going from:
export default [
js.configs.recommended,
reactConfig.configs.recommended,
reactConfig.configs["jsx-runtime"],
to
export default [
js.configs.recommended,
reactRecommendedConfig,
reactJSXConfig,
Note: the name of the variables you use is up to you. You could have imported them as for example rrcr
and rrcjsx
and it would also be fine, although I generally try to maximise obviousness which is why I use relatively long variable names.
This finally gets us to an ESLint running without errors because our code is impolute 😁
> zero-to-hello@1.0.0 lint
> eslint .
To test that we're actually able to catch errors, go to index.jsx and enter SOME NONSENSE
(literally) and save the file, so that it looks like this:
import { createRoot } from 'react-dom/client';
SOME NONSENSE
document.addEventListener('DOMContentLoaded', () => {
let appElement = document.getElementById('app');
let appRoot = createRoot(appElement);
appRoot.render(<h1>hello via React!</h1>);
// Roughly equivalent to the above
// appElement.innerHTML = '<h1>hello from my app</h1>';
});
Then run the linter:
$ npm run lint
> zero-to-hello@1.0.0 lint
> eslint .
.../zero-to-hello/src/index.jsx
2:6 error Parsing error: Unexpected token NONSENSE
✖ 1 problem (1 error, 0 warnings)
Hooray! An error!
So, this works, it's time to undo the changes in index.jsx
and commit the changes and we'd be done!
Except for one thing...
Add the missing "jsx": true
In the ESLint documentation and in many, many snippets of code you'll find online they mention you have to add this section to parserOptions
if you want to use JSX with ESLint:
ecmaFeatures: {
jsx: true
}
Why 'JSX' is part of the ECMA features configuration so far escapes me, and the documentation is not quite shedding sufficient light to enlighten me yet, but I added this to the configuration and tested that intentionally introduced errors in JSX files are still picked up, before committing this last bit.
And I will dig into what does this feature actually enable in a separate post, again!
Bump the version
For the sake of doing a good job I also increased the package version from 1.0.0
to 1.1.0
and added a couple of tags for future reference.
git tag updated-eslint-config
git tag v1.1.0
I've also merged the branch back to main, although I've left the branch separate and haven't deleted it, for future reference. (You never know...)
Conclusion
You can look at the before (all files or config file) and after (all files or config file).
The new ESLint config system is easier to understand, once you get pointed towards the right direction. I do like having all the 'truth' in just one file that joins everything together in an explicit manner. The previous system was very complicated to reason about, since there were multiple ways of doing things, and many seemed implicit.
Making things explicit inevitably makes the config file longer, but there should be less confusion now.
That said, you do need to understand the new philosophy. I hope the post helped with that.
If not, there are more things you can read below!
Further reading
There's a great description of the new config system in ESLint's blog:
- part 1: background and context i.e. how we ended where we ended
- part 2: design of flat config
- part 3: how to use the new flat config
Sometimes descriptions of the new system are not enough, and users are the ones who will pick flaws in your documentation, and file bugs. That's where I found answers to why ignores
was being ignored:
Finally, a closing anecdote.
I initialised the famous Storybook in another, new project. Do you know what its assistant did? It created an entry in package.json
, from all places, with its preferred ESLint config! So you see how, as I said, there are plenty of opportunities to apply this newly acquired knowledge about flat configs in ESLint to make sure we use a SINGLE configuration file and not pieces of configuration scattered across multiple files in our projects! 🤪
Happy migrating!