Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Compare the Nx approach

Setting Up Shared ESLint Configs in Workspaces

🚨 Exact Symptom

ESLintError: Cannot find module '@myorg/eslint-config' or its corresponding type declarations.

Secondary Manifestation: 'eslint:config' resolution fails when running eslint from a nested package directory during CI/CD pipeline execution or local development.

🔍 Root Cause Analysis

ESLint's Flat Config (eslint.config.js) resolves modules strictly relative to the current working directory (CWD). In modern pnpm/npm/yarn workspaces, this resolution fails when:

  1. The shared config package lacks a package.json exports field, causing Node's ESM/CJS resolver to ignore the symlinked node_modules tree.
  2. Workspace dependencies are declared incorrectly (e.g., missing workspace:* protocol), breaking the virtual dependency graph.
  3. Legacy .eslintrc.* files or caches persist, conflicting with the new flat config resolution algorithm.
  4. Incorrect files/ignores glob patterns in the shared config exclude the consumer's source tree.

Properly mapping dependency resolution across isolated package boundaries is a core requirement for stable Monorepo Architecture & Orchestration.

🛠️ Diagnostic & Resolution Runbook

Execute the following steps in order to restore pipeline stability and local lint execution.

1. Declare Workspace Dependency

Add the shared config to each consuming package's package.json:

// packages/consumer-app/package.json
{
 "devDependencies": {
 "@myorg/eslint-config": "workspace:*" 
 }
}

(Use "*" for npm/yarn if workspace: protocol is unsupported.)

2. Expose Entry Point in Shared Config

Modern bundlers and ESLint require explicit exports mapping. Add this to the shared package:

// packages/eslint-config/package.json
{
 "name": "@myorg/eslint-config",
 "main": "./eslint.config.js",
 "exports": {
 ".": "./eslint.config.js"
 },
 "type": "module"
}

3. Implement Flat Config in Shared Package

Export a default array of configuration objects. Avoid CommonJS module.exports in ESM workspaces.

// packages/eslint-config/eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default [
 js.configs.recommended,
 ...tseslint.configs.recommended,
 {
 files: ["**/*.ts", "**/*.tsx"],
 rules: {
 "@typescript-eslint/no-explicit-any": "warn"
 }
 }
];

4. Extend in Consumer Workspace

Create eslint.config.js at the consumer package root:

// packages/consumer-app/eslint.config.js
import sharedConfig from "@myorg/eslint-config";

export default [
 ...sharedConfig,
 {
 files: ["src/**/*.{ts,tsx}"],
 rules: {
 "no-console": "warn"
 }
 }
];

5. Rebuild Dependency Tree & Clear Cache

Resolve symlink/hoisting conflicts and purge stale legacy caches:

# Remove legacy config files if present
rm -f .eslintrc.js .eslintrc.json .eslintrc

# Rebuild node_modules
pnpm install --force # or npm install / yarn install

# Clear ESLint cache
rm -rf .eslintcache

6. Validate Resolution Path

Run ESLint with debug tracing to confirm the shared config loads correctly from the consumer directory:

cd packages/consumer-app
npx eslint --debug . 2>&1 | grep -E "eslint:config|Loading|Resolved"

Expected Output: eslint:config Loading config from @myorg/eslint-config followed by successful rule application. No fallback to legacy config paths.

📦 Required Configuration Artifacts

Artifact Purpose
Root pnpm-workspace.yaml / package.json workspaces array Defines package boundaries and symlink topology
Shared package.json with exports field Enables modern module resolution for the config package
Shared eslint.config.js Centralized rule definitions (Flat Config format)
Consumer package.json with workspace dependency Links consumer to shared config in the virtual graph
Consumer eslint.config.js Extends shared config and applies package-specific overrides

🛡️ Prevention & Pipeline Hardening

  • Pin Toolchain Versions: Lock eslint, @eslint/js, and typescript-eslint to identical versions across all workspaces. Use a single devDependencies block or workspace aliasing to prevent version drift that breaks shared config resolution.
  • Enforce Flat Config Only: Add a CI check that fails if .eslintrc.* files are committed. ESLint v9+ deprecates legacy configs, and mixed formats cause unpredictable resolution.
  • Standardize Dependency Graphs: Review workspace filtering and dependency hoisting strategies regularly. Refer to Workspace Configuration Deep Dive for advanced dependency isolation patterns.

❓ Troubleshooting FAQ

How do I handle legacy .eslintrc plugins in a Flat Config workspace? Convert them using the eslint-config-flat compatibility layer or migrate to their official Flat Config equivalents. ESLint v9+ does not auto-convert .eslintrc plugins; they must be explicitly loaded via import in eslint.config.js.

Why does ESLint ignore my shared config when running from the monorepo root? Flat Config resolves relative to the CWD. Running from the root without a root-level eslint.config.js or proper files globs causes ESLint to skip nested packages. Always run npx eslint from the target package directory or configure a root config with explicit files: ["packages/**/src/**/*"] patterns.

Can I override specific rules per workspace without duplicating the entire config? Yes. Spread the shared config array first, then append a new config object with targeted files globs and rule overrides. The last matching config object wins in the Flat Config cascade.

How do I ensure TypeScript type definitions resolve correctly for the shared ESLint config? Publish @types/eslint__js and @typescript-eslint/eslint-plugin types alongside the shared config, or ensure the consumer package installs them as devDependencies. Add "types": "./eslint.config.d.ts" to the shared package.json if generating custom type declarations.