Monorepos
How to lint monorepos.
This guide is written for ESLint.
Making the monorepo itself
Take a classic monorepo structure (with ./apps and ./packages workspaces to hold apps and libraries, respectively):
.
├── apps
│ └── my-app
│ ├── index.html
│ └── package.json
├── package.json
└── packages
└── my-lib
├── index.js
└── package.jsonSetting up workspace-level linting
The root-level config
Let's integrate eslint into the repo. We'll need a root-level eslint config and some workspace-level ones as well:
.
├── apps
│ └── my-app
+ │ ├── eslint.config.mjs
│ ├── index.html
│ └── package.json
+ ├── eslint.config.mjs
├── package.json
└── packages
└── my-lib
+ ├── eslint.config.mjs
├── index.js
└── package.jsonI always like to start with typescript-eslint's Getting Started boilerplate:
// @ts-check
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
);The workspace-level configs
Let's add eslint at the workspace level, too:
.
├── apps
│ └── my-app
+ │ ├── eslint.config.mjs
│ ├── index.html
│ └── package.json
├── eslint.config.mjs
├── package.json
└── packages
└── my-lib
+ ├── eslint.config.mjs
├── index.js
└── package.jsonTo extend from the base config (to be clear: you don't have to! It's just if you want to reuse conventions), you'd set up both of the configs like this:
// @ts-check
import tseslint from "typescript-eslint";
import baseConfig from "../../eslint.config.mjs";
export default tseslint.config(
...baseConfig,
// Add extra config in here to override/extend the base config.
);Excluding workspace-level files from root-level linting
If you run npx eslint from the root at this point, it will lint not only the root-level files like package.json (which we do want), but also all the workspace-level files like apps/my-lib/index.js (which we don't want – it's more flexible to be able to lint those separately).
What's worse, when running npx eslint from the root, workspace-level files will be linted using the root-level eslint.config.mjs, but those base rules may not be correct – we need them to be linted via the workspace-level configs.
Don't take my word for it, though – to assess for yourself, run the linting command with debug logs enabled.
npx eslint --debugAmongst the many logs, you'll see a record of which files were visited and which config file was used for each one:
eslint:config-loader [Legacy]: Calculating config for /Users/me/my-monorepo/packages/my-lib/index.js +0ms
eslint:config-loader [Legacy]: Using config file /Users/me/my-monorepo/eslint.config.mjs and base path /Users/me/my-monorepo +0msIgnoring workspaces from the root-level config
So how can we lint just the files in the root, and exclude the workspace files? Simply add an ignores rule for each workspace into the root eslint.config.mjs:
// @ts-check
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
+ { ignores: ["apps", "packages"] },
);Unignoring ignored directories when extending the base config
How config-merging works with ignores
Earlier, you saw that to create each workspace-level config, we extended the root-level config like so:
// @ts-check
import tseslint from "typescript-eslint";
import baseConfig from "../../eslint.config.mjs";
export default tseslint.config(
...baseConfig,
// Add extra config in here to override/extend the base config.
);If you inlined ...baseConfig, you'd see that the workspace-level configs are effectively equivalent to this:
// @ts-check
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
/* START baseConfig */
eslint.configs.recommended,
tseslint.configs.recommended,
{ ignores: ["apps", "packages"] },
/* END baseConfig */
// Add extra config in here to override/extend the base config.
);This means that the workspace-level configs would also end up ignoring apps and packages. But suppose we wanted to have directories of those names within our workspace, how would we unignore them?
Intuitively, you might think we could override previous ignores like this:
// @ts-check
import tseslint from "typescript-eslint";
import baseConfig from "../../eslint.config.mjs";
export default tseslint.config(
...baseConfig,
+ { ignores: [] },
);However, ignores is a special case. It only merges additively. That is to say, declaring ignores again allows you to add extra stuff into it, but you can never remove from it. So what can we do?
A workaround
There's no need to subtract if we never add in the first place. We can change our configs as follows:
The root-level config
Give a name to the ignores rule so that we can filter on it:
// @ts-check
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
- { ignores: ["apps", "packages"] },
+ { name: "root-level ignores", ignores: ["apps", "packages"] },
);The workspace-level configs
Take all the rules from the base config except for the ignores rule.
// @ts-check
import tseslint from "typescript-eslint";
import baseConfig from "../../eslint.config.mjs";
export default tseslint.config(
- ...baseConfig,
+ ...baseConfig.filter(({ name }) => name !== "root-level ignores"),
);It's a bit inelegant, but it does the trick! Alternatively, you could export a configWithoutIgnores from the root-level eslint.config.js to be imported instead of importing the base config directly.