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 as js, json or yml, eslintConfig key in package.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 the env 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 and overrides 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 use eslint.config.js instead
  • parserOptions and env 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 keep package.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:

  1. 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.
  2. 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:

  1. It confirms it is using the flat config! i.e. the new version! Yay!
  2. 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:

  1. We're importing the globals data first
  2. the ... spread operator in this context expands whichever keys are in globals.browser before and adds them as keys into the globals key of languageOptions. Very handy!
  3. 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 with files, 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:

  1. the default configuration starts with a wide net: find all .js, .cjs and .mjs files but ignore patterns such as .git and node_modules directories

  2. Your config file can return n configurations:

    export default [
     { /* configuration 1 */ },
     { /* configuration 2 */ },
     // ...
     { /* configuration n */ }
    ]
    
  3. 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)

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:

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!