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:
- Create a new NPM package, which defines this configuration
- Add that package as a development dependency of my project
- Use
"extends: my-shared-config"
in my project'seslint.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:
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.
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 behaviorrequire("@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.