5 Reasons `no-unsafe-call` Fails in Monorepos (2025 Fix)
Tired of `no-unsafe-call` errors in your TypeScript monorepo? Learn the 5 common reasons this ESLint rule fails and how to fix your setup for good in 2025.
Elena Petrova
Senior Staff Engineer specializing in TypeScript, monorepos, and developer tooling at scale.
You’ve embraced the monorepo life. Your code is organized, dependencies are shared, and collaboration feels streamlined. Then, you decide to enforce stricter type safety with ESLint. You enable @typescript-eslint/no-unsafe-call
, run your linter, and... chaos.
Your console floods with errors, pointing to code you know is correctly typed. It feels like the linter is lying to you. What gives? Why does this crucial rule, designed to prevent runtime errors, become so fragile inside a monorepo?
The truth is, no-unsafe-call
isn’t the problem. It’s a symptom. It’s the canary in the coal mine, telling you that your type-aware linting setup isn’t correctly configured for the complexities of a monorepo. In this post, we’ll break down the five most common reasons this rule fails and show you the modern, holistic fix for 2025.
1. Broken TypeScript Project References
This is, by far, the number one culprit. Monorepos rely on TypeScript’s Project References to understand the dependency graph between your internal packages (e.g., your @my-company/ui
package is used by your @my-company/webapp
).
The Problem: Incomplete Type Graph
When you lint a single package (like webapp
), ESLint needs to know the types of the functions and variables you’re importing from ui
. If ESLint isn’t aware of the project reference, it can’t build a complete type graph. It fails to find the source types for the imported code and, as a fallback, treats them as any
. The moment you try to call one of those functions, no-unsafe-call
correctly flags it as an unsafe operation.
The Fix: Point ESLint to All tsconfigs
Your ESLint configuration needs to be aware of your entire monorepo’s structure. In your root .eslintrc.js
, you must configure the parser to find every tsconfig.json
in your project.
// .eslintrc.js
module.exports = {
// ... other configs
parser: '@typescript-eslint/parser',
parserOptions: {
project: [
'./tsconfig.json',
'packages/*/tsconfig.json',
'apps/*/tsconfig.json',
],
tsconfigRootDir: __dirname,
},
// ... rules
};
This tells typescript-eslint
to load all these project configurations, stitch them together, and build the full type graph it needs to work correctly across packages.
2. Mismatched TypeScript Versions
In a large monorepo, it's easy for different packages to end up with slightly different versions of TypeScript in their `devDependencies`. This creates subtle inconsistencies that can break type-aware linting.
The Problem: Type Declaration Drift
Imagine your @my-company/utils
package is built with TypeScript 5.2, generating .d.ts
declaration files. Your webapp
, however, uses TypeScript 5.4. When ESLint (using the webapp
’s TS version) tries to parse the declaration files from utils
, it might interpret them slightly differently, or worse, fail to resolve them properly. This mismatch can cause types to degrade to any
.
The Fix: Enforce a Single Version
Enforce a single TypeScript version across your entire monorepo. The easiest way is to define it in the root package.json
.
If you use pnpm
, you can use the overrides
feature to enforce this strictly:
// root package.json
{
"pnpm": {
"overrides": {
"typescript": "^5.4.0"
}
}
}
For Yarn or npm, you can use `resolutions`. This ensures every package in your workspace uses the exact same TypeScript compiler, eliminating declaration file conflicts.
3. An Incomplete `tsconfig` for Linting
Many projects use a dedicated tsconfig.eslint.json
file to avoid linting build artifacts or test files. But a misconfigured file can easily starve the linter of necessary context.
The Problem: Missing Files
Your primary tsconfig.json
might have a tight "include"
array for optimal build performance. Your `tsconfig.eslint.json` needs to be more expansive. If it doesn't include global type definition files (`.d.ts`), configuration files, or other relevant sources, ESLint won't have the full picture. When it encounters an import it can't trace back to an included file, that import becomes any
.
The Fix: Extend and Include Everything
Create a robust tsconfig.eslint.json
in your project root that extends your base config and explicitly includes all files the linter should be aware of.
// tsconfig.eslint.json
{
"extends": "./tsconfig.json", // Start with the base config
"include": [
"src/**/*.ts",
"apps/**/*.ts",
"packages/**/*.ts",
"test/**/*.ts",
"*.js", // Include root config files
"*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}
Then, update your .eslintrc.js
to point its project
option to this single, comprehensive file. This is an alternative to the glob pattern shown in the first point and can be simpler for some setups.
4. Module Resolution Madness
Monorepos use symlinks to connect internal packages. For example, apps/webapp/node_modules/@my-company/ui
is often a symbolic link to packages/ui
. This can confuse tools that don't use TypeScript's own module resolution logic.
The Problem: ESLint Gets Lost in Symlinks
If your ESLint setup isn’t configured to use TypeScript's resolver, it might try to resolve modules using a more basic Node.js-style lookup. When it hits a symlink, it can get confused about the file's real location and fail to find the corresponding .d.ts
or source files. The result? The module is typed as any
.
The Fix: Trust TypeScript's Resolver
The good news is that if you've correctly set parserOptions.project
as shown in our first point, @typescript-eslint/parser
will automatically use TypeScript's own, superior module resolution logic. This is the intended and most robust way to configure the plugin. You generally don't need extra resolver plugins like `eslint-import-resolver-typescript` anymore, as long as your `tsconfig.json` files are correctly referenced.
5. Aggressive Caching Gone Wrong
Modern monorepo tools like Turborepo and Nx are incredibly fast because they cache everything, including build artifacts (like .d.ts
files) and task results (like lint reports).
The Problem: Linting Against Stale Types
Here’s a common scenario: You change a function signature in your ui
package, but your monorepo tool's cache invalidation isn't configured perfectly. You run the linter on your webapp
. The tool sees that `webapp`'s source files haven't changed and uses a cached lint result. Alternatively, it might re-lint `webapp` but run it against a stale, cached version of the ui
package's declaration files. In either case, the linter is working with outdated information, leading to incorrect no-unsafe-call
errors (or a lack thereof).
The Fix: Define Correct Cache Dependencies
This is less about ESLint config and more about your monorepo tool config. Ensure your linting task depends on the build artifacts of its internal dependencies. In Turborepo, this means setting up your `turbo.json` pipeline correctly.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"lint": {
// This is the key!
// Linting depends on the build artifacts of its workspace dependencies.
"dependsOn": ["^build"]
}
}
}
This tells Turborepo, "Before you lint a package, make sure all of its dependencies have been successfully built." This ensures your linter is always running against the latest type definitions.
The Holistic 2025 Fix: A Unified Configuration
The real fix isn't just one of these things; it's a combination of all of them. A modern, robust, type-aware linting setup in a monorepo for 2025 looks like this:
- Single Source of Truth: Use your root
package.json
to enforce a single version of TypeScript and@typescript-eslint/*
packages across all workspaces. - Project-Aware ESLint: Your root
.eslintrc.js
is configured withparserOptions.project
using globs to find everytsconfig.json
in your project. - Smart Task Orchestration: Your monorepo tool (Turborepo, Nx) is configured so that `lint` tasks explicitly depend on the `build` tasks of their dependencies.
- Clean State: When in doubt, run a full clean and rebuild. (e.g.,
turbo clean && turbo build
). If that fixes the problem, it’s a strong sign your cache dependency graph is wrong.
Here's a final look at the cornerstone of the fix, your root .eslintrc.js
:
// .eslintrc.js in monorepo root
module.exports = {
root: true, // Important!
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
// This enables type-aware rules
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
parserOptions: {
// The magic for monorepos
project: ['./tsconfig.json', './packages/*/tsconfig.json', './apps/*/tsconfig.json'],
tsconfigRootDir: __dirname,
},
rules: {
// Now this rule will work reliably!
'@typescript-eslint/no-unsafe-call': 'error',
// ... and other unsafe rules
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
},
ignorePatterns: ['node_modules', 'dist', 'build', '.turbo'],
};
Conclusion
Seeing no-unsafe-call
errors on seemingly safe code in a monorepo is a classic rite of passage. But it's not a bug in ESLint; it's a signpost pointing to a misconfiguration in your complex development environment. By ensuring consistent TypeScript versions, correctly configuring project references for ESLint, and defining a logical build pipeline for your monorepo tool, you can eliminate these phantom errors for good.
Get your setup right, and you'll unlock the true power of type-aware linting: catching real bugs before they hit production, backed by the scalable architecture of a monorepo.