• Home
  • Blog
  • Embrace the Future: Navigating the New Flat Configuration of ESLint

Embrace the Future: Navigating the New Flat Configuration of ESLint

This article was published on Jul 20, 2023, and takes approximately 12 minutes to read.

ESLint completed 10 years this year, and it's definitely one of the most important tools of any JavaScript project.

That's because it enforces some rules while coding which might define some code standards or, in some cases, even help us not to write buggy code.

I've been coding for almost 8 years strictly in JavaScript, and I can say the ecosystem has changed a ton in all those years.

ESLint, though was one of the libs I can remember that it preserves the roots. Even though it now has much more power than in the past, the API looks the same, and the way of writing a config looks the same, and this is good and bad at the same time.

It is good because it reduces the overhead we have in the JS ecosystem of dealing with new changes every week/month, but bad because it holds the tool for evolving and simplifying how we define a configuration.

Please, don't get me wrong. I'm not blaming the maintainers or saying anything bad. Preserving what is battle tested and works really well is a great ability, but at some point, we have to change... for the better.

Of course, the ESLint creator and maintainers ack acknowledged those pain points, and they have come up with a new config architecture.

If you're into "behind-the-scenes", I do recommend you to read the 3 posts of Nicholas C. Zakas (creator and maintainer), where he explains ESLint's history, where and why some decisions were taken, and an overview of what's all about this new architecture.

What is this post about?

ESlint has the blog posts and the official documentation for the new configuration file, and you should definitely reach out to them.

Think about this post as a complete "walk through", a fan guide that collects a bunch of information from the official resources, from my experience migrating dozens of repository configurations, a bunch of insights, and put in a single place.

Here you'll find in simple terms what has changed and a project demo (including both new and old configuration approaches), which you can run on your machine or in a stack blitz container.

You can also find all mentioned links in the References section.

My attempt is to leave the abstract and put into action all reading I did to understand what this new configuration is all about. I hope this guide finds you well. ๐Ÿ˜

What is this post *not* about?

This post is not part of any official docs and doesn't intend to be. If you're having any trouble, reach out to ESLint.org, and try to find your problem there.

I'm not going to dive much into configuration details. If you're unsure how a specific configuration works or what it's used for, go to the official docs.

Before start

Last topic before we start (I promise).

At the moment I'm writing this guide, the latest ESLint version is 8.45.0.

Maybe when you are reading this, a new version came up, and some approaches might have changed.

For this reason, if you try something here that's not working, reach out to the official docs.

According to Nicholas's post, Flat config was introduced at ESLint v8.21.0, so make sure you installed this one or the latest version (with probably some bug fixes and significant improvements).

Now, without further due, let's start.

Migrating

Nomenclature

First things first, it's important to understand the terms.

The old configuration is often referred as eslintrc, while the new approach is called flat config or new config file.

For the sake of this guide, I'll be using eslintrc for the old architecture and flat config for the new one.

New config file name and extension

In eslintrc, we could give various names and extensions to an ESLint config:

  • .eslintrc (without extension but matches the JSON syntax)
  • .eslintrc.json
  • .eslintrc.js
  • .eslintrc.yaml / yml
  • etc...

Now in the flat config, we have a unique file name standard: eslint.config.js.

You'll understand why when you see the most significant changes in a sec and if you read the official announcement blog post.

In a nutshell, it lets ESLint use many native resources from Node without any cumbersome implementation.

So, to start your migration, create a new file instead of renaming your old .eslintrc*.

Configuration file extension

In eslintrc, when we're in an ESM repository (that defined "type": "module" in the package.json, we must have the eslintrc with the .cjs extension:

-.eslintrc.js
+.eslintrc.cjs

In flat config, regardless of whether your repository is ESM, your eslint.config must always have the .js extension.

If you try eslint.config.mjs or eslint.config.cjs the CLI won't be able to find the config file.

CLI loading the config file

The same CLI still supports both eslintrc and flat config files.

Testing the latest version, I notice that if you have both in your repository, it'll first try to load the eslint.config.js. If not found, then It'll look for .eslintrc*.

This behavior could be inverted if you run the eslint binary with ESLINT_USE_FLAT_CONFIG=false.

ESM is finally supported

In the eslintrc, we could only write CJS syntax.

With the new flat config, we can write either ESM or CJS.

Writting one or another will depends on your ecosystem.

Different returned type

One of the most significant changes is that we no longer can default return an object as we did in eslintrc:

.eslintrc.js
module.exports = { // <- object
  // configs here
}

Instead, in the flat config, we must return an array of configs:

eslint.config.js
export default [
  // config objects here
];

// Or if you're using CJS:

module.exports = [
  // config objects here
];
More on the config shape in a moment

With an array of objects, ESLint will now "merge" all objects in this list, and it'll gaining precedence always the latest defined config.

No more "--ext" CLI option

In the eslintrc approach, it's common we need to list the files extension we want ESLint to read:

pnpm eslint . --ext .js,.ts,.tsx

This is no longer supported or needed in flat config.

Instead, we will declare the files we want ESLint to scan via the property files:

eslint.config.js
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  {
    files: ["**/*.js", "**/*.ts", "**/*.tsx"],
    rules: {
      semi: "error",
      "prefer-const": "error",
    },
  },
];

The files property expect an array of globs patterns that would be used via a library called minimatch.

About minimatch

One tiny problem I had was with the old way I was defining the list of files to be read by ESLint.

In the eslintrc, I had some declarations like this:

.eslintrc.js
/** @type {import('eslint').Linter.Config} */
module.exports = {
  overrides: [
    {
      files: ["*.js", "*.mjs"],
      rules: {
        // some rules
      },
    },
  ],
};

While migrating to flat config, I noticed some .js and .mjs files inside folders weren't being linted.

After a while, I realized that was because minimatch has a slightly different engine, and I had to consider the folder pattern:

-["*.js", "*.mjs"]
+["**/*.js", "**/*.mjs"]

So, if ESLint is not reading some of your files, take a look to see if you're writing the pattern correctly.

No more "overrides"

Because we now have the files property, we no longer need to use overrides to override some rules for a specific file type.

Instead, you would have to declare another object in the array of configurations for both extensions, for example:

eslint.config.js
/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  {
    files: ["**/*.js"],
    rules: {
      semi: "error",
      "prefer-const": "warn",
    },
  },
  {
    files: ["**/*.ts"],
    rules: {
      "prefer-const": "error",
    },
  },
];

In this config, using let without reassign will throw only a warning message for JavaScript files, but for TypeScript files, the same rule will throw an error.

No more "ignorePatterns"

In eslintrc, to ignore some specific file extensions or folders from a set of rules, we would need to use the ignorePatterns property:

.eslintrc.js
module.exports = {
  // ignorePatterns: ["**/mocks/*.ts"], // here for globals
  overrides: [
    {
      files: ["**/*.ts"],
      ignorePatterns: ["**/mocks/*.ts"], // or here to be more specific
      rules: {
        //...
      },
    },
  ],
};

In the flat config, we have a new property called ignores, which behaves the same as files but to not include them:

eslint.config.js
export default [
  {
    files: ["**/*.ts"],
    ignores: ["**/mocks/*.ts"],
    rules: {
      //...
    },
  },
];

No more ".eslintignore"

In eslintrc architecture, when we want to exclude a few folders globally from ESLint, we would need to create a .eslintignore:

.eslintignore
dist/
node_modules/
bin/
build/

Or, we could also use the top-level ignorePatterns in the config:

.eslintrc.js
module.exports = {
  ignorePatterns: ["dist/**", "node_modules/**", "bin/**", "build/**"],
};

In the Flat config, to have the same effect, all we need to do is to add an object as the first array element and declare the ignores:

eslint.config.js
export default [
  // global ignores
  {
    ignores: ["dist/**", "node_modules/**", "bin/**", "build/**"],
  },
  {
    files: ["**/*.js", "**/*.ts"],
    rules: {
      "prefer-const": "error",
    },
  },
];

No more "extends"

In the eslintrc architecture, when we want to use existing configurations and work on top of that, we would use the property extends.

Let's say we want to use the base airbnb configuration.

The first step is to install eslint-config-airbnb-base. Then, we would need to define in the extends property, the name of that configuration that comes after the eslint-config-* prefix:

.eslintrc.js
module.exports = {
  extends: ["airbnb-base"],
  rules: {
    "airbnb-base/arrow-body-style": "off",
  },
};

We can no longer declare the extends as strings in the flat config. Instead, we need to import that config and add it as an item of our array:

eslint.config.js
import airbnbConfig from "eslint-config-airbnb-base"; // import the config

export default [
  airbnbConfig, // declare as an item
  {
    rules: {
      "airbnb-base/arrow-body-style": "off",
    },
  },
];

In a nutshell, now, we will import the config as a regular library (or require it in CJS) and define the whole default export as an item of our config list.

This also means that the publishing of configs is totally relaxed. You no longer need to prefix the lib with eslint-config-*, and you also no longer need to do a default export.

Plugins need to be imported

Like extends, plugins will follow the same principle: we need to import and reference them inside one of one config object:

eslint.config.js
import jsdoc from "eslint-plugin-jsdoc";

/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  {
    files: ["**/*.js"],
    plugins: {
      jsdoc,
    },
    rules: {
      // Other rules...
      // JSDoc
      "jsdoc/require-example": "error",
    },
  },
];

In the eslintrc, plugins must have a prefix (eslint-plugin-*) as well.

This means that when we install eslint-plugin-jsdoc , we must reference it as plugins: ['jsdoc'], and this name after the prefix was used as the namespace for the rules in this plugin.

In flat config, we can change the namespace to whatever we want because it's an object, and we have full control of it.

In the same example above, if we want to use jsd as the namespace, we could do the following:

eslint.config.js
import jsdoc from "eslint-plugin-jsdoc";

/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  {
    files: ["**/*.js"],
    plugins: {
      jsd: jsdoc,
    },
    rules: {
      // Other rules...
      // JSDoc
      "jsd/require-example": "error",
    },
  },
];

The "languageOptions" property

In Flat config, a bunch of options that were spread in the eslintrc configuration object were grouped into the new languageOptions, such as:

  • ecmaVersion
  • sourceType
  • globals
  • parser
  • parserOptions
eslint.config.js
import babelParser from "@babel/eslint-parser";
import babelPresetEnv from "@babel/preset-env";
import babelPresetReact from "@babel/preset-react";

/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  {
    files: ["**/*.js"],
    languageOptions: {
      ecmaVersion: 2021,
      sourceType: "module",
      parser: babelParser,
      globals: {
        // global variables
      },
      parserOptions: {
        requireConfigFile: false,
        babelOptions: {
          babelrc: false,
          configFile: false,
          // your babel options
          presets: [babelPresetEnv, babelPresetReact],
        },
      },
    },
  },
];

No more "env" option

In eslintrc, we have an option called env, where we can signal ESLint which environment we're running.

For example, for telling ESLint we're in the context of NodeJS and have the global NodeJS global variables, we would define:

.eslintrc.js
module.exports = {
  env: {
    node: true,
  },
};

By doing this, ESLint will inject all "node" global variables and won't complain when we use them.

In flat config, this property no longer exists.

Instead, we have a property inside languageOptions called globals, where we define which global variable is available:

eslint.config.js
export default [
  {
    files: ["**/*.test.js", "**/*.spec.js"],
    languageOptions: {
      globals: {
        it: "readonly",
        expect: "readonly",
        describe: "readonly",
      },
    },
  },
];

In this example, for the test files, we're going to say it'll are some global variables called "it", "expect," and "describe," and ESLint won't throw errors when we use those functions.

According to Nicholas, because we now have so many different JavaScript environments, maintaining more and more envs adds an unnecessary overhead in ESLint. Now, the responsibility to determine what will be globally available is ours.

Don't worry much about this, though. Soon, we're gonna have "eslint-globals" packages for the environments the community maintains for us.

Also, the ESLint team recommends we use a package called globals, which is a simple json that groups what is globally available for each environment:

eslint.config.js
import globals from "globals";

export default [
    {
        files: ["**/*.js"],
        languageOptions: {
            globals: {
                ...globals.browser,
                myCustomGlobal: "readonly"
            }
        }
    }
];

Custom rules made easier

If you have ever had to write a custom rule in the eslintrc system, you might know the pain.

You wrote the rule matcher, but now, you must specify a new flag parameter called --rulesdir in the CLI pointing to where that folder lives.

This param will signal ESLint that custom rules must be loaded in a specific folder.

Finally, this is no longer needed.

Instead, we need to import the rule directly to our config:

eslint.config.js
import noDoubleUnderscore from "./eslint/rules/no-double-underscore-variable";

/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  {
    plugins: {
      internal: {
        rules: {
          "no-double-underscore-variable": noDoubleUnderscore,
        },
      },
    },
    rules: {
      semi: "error",
      "prefer-const": "error",
      "internal/no-double-underscore-variable": "error",
    },
  },
];

No more "eslint:recommend"

In eslintrc, we could enable some recommended rules from ESLint by doing the following:

.eslintrc.js
module.exports = {
  extends: ["eslint:recommended"],
  // other configs
};

In flat config, this is no longer possible. Instead, we need to install another package called @eslint/js:

pnpm add --dev @eslint/js

Then, we have to import the "recommended" config and spread it into our rules:

eslint.config.js
import js from "@eslint/js";

export default [
  // apply recommended rules to JS files with an override
  {
    files: ["**/*.js"],
    rules: {
      ...js.configs.recommended.rules,
      "no-unused-vars": "warn",
    },
  },
];

Fixing compatibility issues

Transition periods are always hard, especially in the huge ecosystem of ESLint.

If you're extending from pre-existing configurations and plugins, there's a chance that those configurations won't work properly with the new flat config strategy.

For example, let's get the eslint-config-airbnb-base.

If we try to use it as the Flat config says:

eslint.config.js
import airbnb from "eslint-config-airbnb-base";

/** @type {import('eslint').Linter.FlatConfig[]} */
export default [airbnb];

At least in v15.0.0, you will get the following error message:

Oops! Something went wrong! :(

ESLint: 8.45.0

Error: Unexpected key "extends" found.

And that's because if you inspect their source code, you'll see they use the old eslintrc syntax with extends property.

Official utility for the rescue

The ESLint team acknowledged that would be an issue and created a package called @eslint/eslintrc.

This package provides a mechanism for backward compatibility between eslint and flat config during the transition period.

We can use extends, env, plugins, or a full configuration (check their README for more details).

For ESM and CJS, we would need to use a FlatCompat class, creating a "compat" object.

The main difference is that because in ESM, we don't have __dirname globally available, we might need to use the common workaround using URL:

eslint.config.js
import { FlatCompat } from "@eslint/eslintrc";
import path from "path";
import { fileURLToPath } from "url";

// mimic CommonJS variables -- not needed if using CommonJS
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const compat = new FlatCompat({
  baseDirectory: __dirname, // optional; default: process.cwd()
  resolvePluginsRelativeTo: __dirname, // optional
});

Now we have the "compat" object, to fix the airbnb-base usage, we could do the following:

eslint.config.js
// flat compat setup

/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  ...compat.extends("airbnb-base"),
  {
    rules: {
      "arrow-body-style": ["error", "always"],
    },
  },
];

It would also work in case we wanted to keep the whole configuration in an eslintrc way but inside the flat config:

eslint.config.js
// flat compat setup

/** @type {import('eslint').Linter.FlatConfig[]} */
export default [
  ...compat.config({
    // eslintrc config shape
    extends: ["airbnb-base"],
    rules: {
      "arrow-body-style": ["error", "always"],
    },
  }),
];

Personally, I've been using a mix of setups in my monorepo and haven't had any problems using the FlatCompat utility.

The main point here is: while migrating, if you have any problem with the existing plugins or configs you have and/or you are not sure how to declare the plugin in the new strategy, you can rely on this FlatCompat until the authors of those libraries add support for the new flat config.

VSCode plugin

If you use VSCode, you might have installed the ESLint plugin my Microsoft.

If you don't use it and use ESLint, I'd strongly recommend to do so. It'll give you a bunch of visual hints of what's wrong in your code based on your eslint config.

Though, it doesn't support the flat config out of the box.

Instead, we have to define either in our user or workspace settings the following property:

settings.json
{
  "eslint.experimental.useFlatConfig": true
}

I do recommend doing it per workflow, though. That's because if you enable it in your user profile by default, you might face issues when you're in a project using the eslintrc.

Conclusion

That's all, folks.

I hope this guide was somehow useful for you, and I strongly encourage you to do this migration.

The more we embrace the new standards, the less legacy code and technical debits we have in our code base.

Again, if you have any trouble doing this migration, check out the official docs, the GitHub issues, discussions, and the community.

Use the right terms, and you should be good.

Ciao! ๐Ÿ‘‹

Resources