Web Development

The Ultimate 2025 Guide to ESLint Generics in Monorepos

Tired of fighting ESLint errors with TypeScript generics in your monorepo? Our ultimate 2025 guide provides the configs and patterns you need for a clean setup.

A

Alexei Petrov

A Senior Frontend Architect specializing in large-scale TypeScript applications and build systems.

7 min read12 views

Tired of fighting with ESLint over TypeScript generics in your monorepo? You’re not alone.

You’ve crafted a beautiful, type-safe generic function. It works flawlessly in your IDE. TypeScript is happy. You’re happy. You commit your code, the CI pipeline kicks off, and... failure. A cryptic ESLint error screams about a type it can’t understand or a path it can’t resolve. The very type safety you worked so hard to achieve is now the source of your tooling headache.

If this scenario feels painfully familiar, this guide is for you. The combination of ESLint, TypeScript's powerful generics, and the complex, cross-package nature of monorepos creates a unique set of challenges. But fear not. By understanding the core of the problem and implementing modern configuration patterns, you can achieve a harmonious, efficient, and reliable linting setup. Let’s dive into the ultimate guide for 2025 and beyond.

Why Generics and Monorepos are a Tricky Mix

At first glance, it seems like these tools should just work together. So, what’s the issue? The conflict arises from three distinct operational models:

  • TypeScript Generics: They are placeholders for types. Their true meaning is only understood within the context of the entire program, including how they are imported and used across different files and packages.
  • Monorepos: They encourage breaking down a large application into smaller, interconnected packages (e.g., @my-app/ui, @my-app/api, @my-app/utils). A generic type defined in @my-app/utils might be consumed by both the UI and API packages.
  • ESLint: By default, ESLint is a single-file processor. It lints one file at a time. To understand complex types that cross file boundaries, it needs help.

When ESLint lints a file in your @my-app/ui package that uses a generic from @my-app/utils, it doesn't inherently know how to find and interpret the source definition of that generic. It lacks the project-wide context, leading to parsing errors and incorrect rule applications.

The Core Problem: ESLint's Parser Context

The magic (and the trouble) happens within @typescript-eslint/parser, the component that allows ESLint to understand TypeScript code. For it to analyze type information correctly—which is essential for rules involving generics—it needs to be configured to look at your entire project, not just isolated files.

This is done via the parserOptions.project setting in your .eslintrc.js file. When you provide a path to your tsconfig.json, you're telling the parser, "Hey, don't just look at this single file. Load up the entire TypeScript project defined by this config so you can understand all the types, interfaces, and generics involved." In a monorepo, the complexity is that you often have multiple tsconfig.json files, and choosing the right one is paramount.

Setting Up Your Monorepo for Success (The 2025 Way)

A solid foundation is key. A modern monorepo setup relies on TypeScript Project References and a hybrid ESLint configuration strategy.

Workspace Configuration with tsconfig.json

Advertisement

First, ensure your monorepo uses TypeScript's Project References. This is the canonical way to link packages within a TypeScript monorepo.

Your root tsconfig.json might look like this, simply referencing the packages in your workspace:

// ./tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/utils" },
    { "path": "./packages/ui" },
    { "path": "./packages/api" }
  ]
}

And a package that depends on another, like @my-app/ui depending on @my-app/utils:

// ./packages/ui/tsconfig.json
{
  "extends": "../../tsconfig.base.json", // A base config with compilerOptions
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../utils" }
  ]
}

Centralized vs. Package-Level ESLint Configs

You have two main approaches for your ESLint configuration, but the hybrid model is often the most effective.

ApproachProsCons
Root-Only ConfigSingle source of truth for rules. Easy to maintain consistency.Difficult to manage package-specific paths and tsconfig.json files. Can become a monolithic, complex file.
Package-Level ConfigsPerfectly scoped to each package. parserOptions.project is simple to set.High duplication of rules. Can lead to configuration drift between packages.
Hybrid (Recommended)Combines the best of both. A base config at the root with shared rules, extended by small, package-level configs.Slightly more setup initially, but far more scalable.

In the hybrid model, you'd have a .eslintrc.js in the root defining all your rules, plugins, and parsers, but without the parserOptions.project setting. Then, in each package, you create a tiny .eslintrc.js that extends the root config and adds the project path.

// ./packages/ui/.eslintrc.js
module.exports = {
  extends: ['../../.eslintrc.js'], // Extend the root config
  parserOptions: {
    // Point to the tsconfig for this specific package
    project: './tsconfig.json',
    tsconfigRootDir: __dirname, // Important for resolving paths correctly
  },
};

Leveraging typescript-eslint's project Setting

The parserOptions.project property is your most important tool. As of 2025, the best practice is to create a dedicated tsconfig.eslint.json file. This file extends your main tsconfig.json but includes all files you want to lint, including configuration files and test files, which you might normally exclude from a production build.

// ./packages/ui/tsconfig.eslint.json
{
  // Inherit all the settings from the main tsconfig
  "extends": "./tsconfig.json",
  // Include all files that ESLint should be aware of
  "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "../utils/src/**/*.ts"]
}

Then, your package-level .eslintrc.js points to this new file:

// ./packages/ui/.eslintrc.js
module.exports = {
  extends: ['../../.eslintrc.js'],
  parserOptions: {
    project: './tsconfig.eslint.json', // Point to the dedicated ESLint tsconfig
    tsconfigRootDir: __dirname,
  },
};

This approach gives ESLint's parser the complete context it needs to trace your generic types back to their source in another package, finally resolving those frustrating errors.

Solving Common Generic-Related ESLint Errors

Let's tackle some common error messages and their solutions within this new structure.

Error: Parsing error: "parserOptions.project" has been set for @typescript-eslint/parser. The file does not match your project config.

Cause: The file you are linting is not included in the include array of the tsconfig.json file you specified in parserOptions.project.

Solution: This is exactly why tsconfig.eslint.json is a best practice. Ensure its include pattern is broad enough to cover all relevant files. For example, "include": ["src/**/*.ts", "tests/**/*.ts", ".eslintrc.js"].

Error: Unable to resolve path to module '@my-app/utils'. (import/no-unresolved)

Cause: This is from eslint-plugin-import. It doesn't know how to resolve your monorepo's internal package aliases (like @my-app/utils).

Solution: You need to give the import plugin a helping hand. Install eslint-import-resolver-typescript and configure it in your root .eslintrc.js.

// ./.eslintrc.js
module.exports = {
  // ... other settings
  settings: {
    'import/resolver': {
      typescript: {
        project: 'packages/*/tsconfig.json',
      },
    },
  },
};

Advanced Patterns & Best Practices

Using eslint-import-resolver-typescript

As shown above, this resolver is non-negotiable in a TypeScript monorepo. It reads your tsconfig.json files (including the paths and references) to teach ESLint's import plugin how your workspace is structured. The glob pattern packages/*/tsconfig.json is powerful, as it automatically discovers all your packages.

Conditional ESLint Overrides

Your monorepo might contain different types of packages (e.g., React, Node.js, tests). Use ESLint's overrides key in your root config to apply different rules or settings to different subsets of files.

// ./.eslintrc.js
module.exports = {
  // ... base config
  overrides: [
    // Apply React-specific rules only to the 'ui' package
    {
      files: ['packages/ui/**/*'],
      extends: [
        'plugin:react/recommended',
        'plugin:react-hooks/recommended',
      ],
    },
    // Apply Jest rules only to test files
    {
      files: ['**/__tests__/**/*', '**/*.test.ts'],
      extends: ['plugin:jest/recommended'],
    },
  ],
};

Performance Tuning in Large Monorepos

Linting an entire monorepo with type-aware rules can be slow. Here are two tips for 2025:

  1. Use the Cache: Always run ESLint with the --cache flag (e.g., eslint . --cache). This creates a cache file and only lints files that have changed, drastically speeding up subsequent runs.
  2. Scoped Linting: Use your monorepo tooling (like Nx, Turborepo, or Lerna) to only lint affected packages. For example, npx turbo run lint will intelligently run the lint script only in packages that have changed since the last commit.

The Future: What's Next for ESLint and TypeScript?

The collaboration between the ESLint and TypeScript teams is stronger than ever. We can expect continued performance improvements in @typescript-eslint, especially for large, type-heavy projects. There's also ongoing work to make the configuration less complex, potentially with better auto-detection of monorepo structures in the future. Tools like Rome and Biome are also emerging as all-in-one formatters/linters, but for now, ESLint remains the undisputed king of customizability and power in the JavaScript ecosystem.

Conclusion: Taming the Beast for Good

The friction between ESLint, TypeScript generics, and monorepos stems from a context problem: ESLint's single-file view clashing with the project-wide nature of types. By adopting a modern setup—leveraging TypeScript Project References and a hybrid ESLint config with dedicated tsconfig.eslint.json files—you provide ESLint with the full picture it needs to work its magic.

It requires some initial setup, but the payoff is immense: a stable, reliable linting system that catches real bugs, enforces consistency, and lets you harness the full power of TypeScript generics without the headache. Now you can get back to what matters most: building amazing software.

Tags

You May Also Like