John Daly

How to make ESLint configs shareable

John Daly

6/14/2022

ESLint is a great tool, and is one of the first things I set up whenever I start a new JavaScript project.

Often times I want to use the same base set of plugins and rules across projects. To do this, I should be able to:

  1. Create a new NPM package, which defines this configuration
  2. Add that package as a development dependency of my project
  3. Use "extends: my-shared-config" in my project's eslint.config.js file

Unfortunately, it's not this straightforward out of the box, due to how ESLint plugins are resolved.

The problem


Lets take a look at an example project:

An overview of how shared ESLint configs break the abstractions that NPM packages provide

Sharing ESLint configurations does not work in the same way that NPM packages do

It seems like sharedEslint.config.js should be able to resolve eslint-plugin-import on behalf of the top-level eslint.config.js file in the project. This is how things work when using other NPM packages. For example, @apollo/client is able to import and use the graphql-tag package.

However we get an error message like this when trying to run the linter:


module.js:338
throw err;
^
Error: Cannot find module 'eslint-plugin-import'
at Function.Module._resolveFilename (module.js:336:15)
at Function.Module._load (module.js:278:25)
at Module.require (module.js:365:17)
at require (module.js:384:17)
at /usr/local/lib/node_modules/eslint/lib/cli-engine.js:106:26
at Array.forEach (native)
at loadPlugins (/usr/local/lib/node_modules/eslint/lib/cli-engine.js:97:21)
at processText (/usr/local/lib/node_modules/eslint/lib/cli-engine.js:182:5)
at processFile (/usr/local/lib/node_modules/eslint/lib/cli-engine.js:224:12)
at /usr/local/lib/node_modules/eslint/lib/cli-engine.js:391:26

The problem is that ESLint plugins are resolved relative to the configuration that is extending the shared configuration, rather than from the shared configuration.

This means is that every plugin must be explicitly installed as a dev dependency of my project, otherwise we run the risk of things breaking if the package manager (npm, yarn, pnpm, etc.) doesn't hoist the plugins to the top-level of the project's node_modules directory. It also means that any engineers that use the sharedLinterConfig package, need to know about the implementation details.

GitHub issue about this design decision

This issue has been a pain point within the JavaScript community

Fixing the problem


There is a workaround, which allows ESLint plugins to be resolved like other modules: @rushstack/eslint-patch.

⚠️ Warning

This fix will change the default behavior of ESLint. This could break packages that operate on the assumption that all ESLint plugins are explicitly installed by a project.

To use the patch, add the following snippet to your shared ESLint configuration file:


// sharedLinterConfig/sharedEslint.config.js
// This will patch ESLint's plugin resolution system, enabling the desired behavior
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
// Custom ESLint config here
}

Now, a package.json that had to be set up like this:


{
"name": "new-web-project",
"version": "0.0.1",
"description": "A new project, which uses a shared ESLint config",
"main": "index.js",
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"sharedLinterConfig": "^1.0.0",
"eslint": "^8.0.0",
"@typescript-eslint/parser": "^5.21.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"typescript": "^4.7.0"
}
}

Can be simplified to:


{
"name": "new-web-project",
"version": "0.0.1",
"description": "A new project, which uses a shared ESLint config",
"main": "index.js",
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"sharedLinterConfig": "^1.0.0",
"eslint": "^8.0.0",
"@typescript-eslint/parser": "^5.21.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^2.7.1",
"typescript": "^4.7.0"
}
}

The sharedLinterConfig will manage the versions of all the various plugins. The engineers working on new-web-project can just use the package and not worry too much about its implementation details. That should be the responsibility of the owners of sharedLinterConfig.

You may notice that there are still some ESLint configuration-related dependencies listed here. Since these are not plugins, they will not be handled by @rushstack/eslint-patch. I'll walk through how to handle these particular packages in the next section.

Bonus Fun: Handling resolution within ESLint plugins

As with any workaround, there are bound to be some rough edges. I ran into a few with the eslint-import-plugin package.

The eslint-plugin-import package allows you to configure resolvers and parsers. This can be very useful if you are using import aliases with a tool like: Webpack, TypeScript, Babel, etc. and want to allow them to be analyzed properly by the linter.

To preserve the abstraction that our sharedLinterConfig package provides, we'll need to make sure that we can properly resolve the resolvers and parsers (oh, the joys of JavaScript tools 😅).

In the case of eslint-plugin-import, here's how I was able to configure things:


// sharedLinterConfig/sharedEslint.config.js
const fs = require('fs');
const path = require('path');
// Patch ESLint's module resolution function, so that plugins are resolved
// relative to the config files that are attempting to load them. Normally,
// ESLint attempts to find plugins relative to the configuration that is
// extending a base config.
require('@rushstack/eslint-patch/modern-module-resolution');
// Find eslint-import-resolver-typescript, eslint-import-resolver-node, and
// @typescript-eslint/parser, starting from this package. We do this because
// these packages are not guaranteed to be hoisted to the root of an app's
// node_modules directory, and might not be resolvable from within the
// 'eslint-plugin-import' package without an absolute path.
const eslintImportResolverTsPath = require.resolve('eslint-import-resolver-typescript');
const eslintImportResolverNodePath = require.resolve('eslint-import-resolver-node');
const eslintImportParserTsPath = require.resolve('@typescript-eslint/parser');
module.exports = {
...
plugins: ['import', ...],
settings: {
...
'import/parsers': {
[eslintImportParserTsPath]: ['.ts', '.tsx']
},
'import/resolver': {
// Resolve modules using Node's standard resolution algorithm
[eslintImportResolverNodePath]: {
extensions: ['.ts', '.tsx', '.js', '.jsx']
}
// Resolve modules using the aliases from `eslint-import-resolver-typescript`.
[eslintImportResolverTsPath]: {},
}
}
}

After doing this, the package.json of our example application looks like this:


{
"name": "new-web-project",
"version": "0.0.1",
"description": "A new project, which uses a shared ESLint config",
"main": "index.js",
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"sharedLinterConfig": "^1.0.0",
"eslint": "^8.0.0",
"typescript": "^4.7.0"
}
}

Closing Thoughts


Using the @rushstack/eslint-patch workaround makes it possible to create shareable ESLint configurations, that provide the same level of abstraction as an NPM package.

This approach is not without its drawbacks, as mentioned in the previous section, and you should consider the tradeoffs carefully.

If you are working in a large engineering organization, the benefits of project standardization and consistency are significant. If you've already got buy-in from teams on a standard set of ESLint rules, I'd highly recommend trying @rushstack/eslint-patch and see how it works for your teams.