TypeScript

Fix `no-unsafe-call` on Generics in Monorepos: 2025

Struggling with the `no-unsafe-call` ESLint error on generic functions in your TypeScript monorepo? This 2025 guide provides a clear, step-by-step fix.

E

Elena Petrova

Senior TypeScript developer specializing in large-scale application architecture and developer tooling.

7 min read11 views

If you're working in a TypeScript monorepo, you've probably felt this exact pain. You craft a beautiful, reusable generic function in your shared utils package. It's clean, it's type-safe, it's perfect. You import it into your main application, wire it up, and then... BAM.

@typescript-eslint/no-unsafe-call: Unsafe call of an `any` typed value.

Your heart sinks. "But it's not `any`!" you protest. "I typed it myself!" You hover over the function in your IDE, and sure enough, it shows the correct generic signature. Yet, ESLint is screaming at you. Welcome to one of the most common and frustrating issues in large-scale TypeScript projects. This guide will walk you through why it happens and, more importantly, how to fix it for good in 2025.

The Root of the Problem: Lost in Translation

First, let's appreciate the @typescript-eslint/no-unsafe-call rule. It's not the enemy; it's a valuable safeguard. It prevents you from invoking a value that TypeScript can't confidently say is a function, protecting you from runtime errors like "myVar is not a function". It triggers when you try to call something typed as any or unknown.

The problem in a monorepo arises from how different packages talk to each other. When your app package imports code from your utils package, it doesn't typically read the original .ts source file. Instead, for performance and modularity, it reads the compiled JavaScript (.js) and the type declaration file (.d.ts) that were generated when you built the utils package.

This is where the "lost in translation" moment happens. Sometimes, during the compilation process, the full richness of a complex generic type isn't perfectly preserved in the .d.ts file. When ESLint, powered by the TypeScript compiler, inspects this imported function, it can get confused and default to the safest possible assumption: that your beautiful generic is actually a dangerous any.

A Practical Example: The "Unsafe" Generic

Let's imagine a typical monorepo structure:

my-monorepo/
├── packages/
│   ├── utils/
│   │   ├── src/create-processor.ts
│   │   └── tsconfig.json
│   └── app/
│       ├── src/index.ts
│       └── tsconfig.json
├── .eslintrc.js
└── package.json

Inside packages/utils/src/create-processor.ts, you have a clever higher-order function:

// packages/utils/src/create-processor.ts

export function createProcessor<T>(processorFn: (data: T) => void) {
  // Returns a function that will execute the provided processor
  return (data: T) => {
    console.log("Processing data...");
    processorFn(data);
  };
}

Now, you use it in your app package:

// packages/app/src/index.ts

import { createProcessor } from "@my-org/utils";

interface User {
  id: string;
  name: string;
}

const processUser = createProcessor<User>((user) => {
  console.log(`Handling user: ${user.name}`);
});

const myUser: User = { id: "1", name: "Alice" };

// ESLint screams here!
processUser(myUser); // <-- Error: Unsafe call of an `any` typed value.

Even though createProcessor clearly returns a function, ESLint has lost the plot. Let's fix it.

The Fix: A Multi-Step Strategy

Fixing this isn't about one magic bullet. It's about ensuring your entire toolchain—from TypeScript to your monorepo manager to ESLint—is configured to work in harmony.

Advertisement

Step 1: Supercharge Your `tsconfig.json`

Your TypeScript configuration is the foundation. The key is to provide as much information as possible across package boundaries. The secret weapon here is declarationMap.

In your shared package's tsconfig (e.g., packages/utils/tsconfig.json), ensure these settings are enabled:

// packages/utils/tsconfig.json
{
  "compilerOptions": {
    // ... your other options like target, module, etc.

    // Crucial for monorepo setups. Enables incremental builds.
    "composite": true,

    // Generates the .d.ts files so other packages know your types.
    "declaration": true,

    // THE HERO! Creates source maps for your .d.ts files.
    // This allows tools like ESLint to trace a type back to its
    // original .ts source, preserving the full generic context.
    "declarationMap": true
  },
  "include": ["src"]
}

Your consuming package (packages/app/tsconfig.json) should then reference the utility package to create the dependency link:

// packages/app/tsconfig.json
{
  "compilerOptions": {
    // ... your app-specific options
    "composite": true
  },
  "references": [
    { "path": "../utils" } // This tells TypeScript about the relationship
  ]
}

Step 2: Master Your Monorepo Build Order

A correct configuration is useless if your packages are built in the wrong order. Your app package's linting process can only succeed if the utils package has already been compiled, generating the necessary .js, .d.ts, and .d.ts.map files.

Modern monorepo tools like Turborepo, Nx, or Lerna are designed for this. You define a dependency graph and they handle the rest. A typical Turborepo setup in turbo.json might look like this:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      // This tells Turborepo that a package's `build` script
      // depends on the `build` script of its dependencies.
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "lint": {
      // The `lint` script should also depend on the build step
      // to ensure types are available before linting.
      "dependsOn": ["^build"]
    }
  }
}

This configuration ensures that running turbo lint will automatically build all dependencies first.

Step 3: Align Your ESLint Configuration

This is the step that trips up most developers. ESLint needs to be explicitly told where to find all the tsconfig.json files in your project so it can build a complete picture of your codebase's types.

In your root .eslintrc.js, configure the parserOptions.project property. This tells @typescript-eslint to load the full type information.

// .eslintrc.js

module.exports = {
  root: true,
  // ... other configs like extends, plugins
  parser: "@typescript-eslint/parser",
  parserOptions: {
    // THIS IS THE KEY!
    // Provide a glob pattern that finds all tsconfig files.
    project: ["./packages/*/tsconfig.json", "./tsconfig.json"],
    tsconfigRootDir: __dirname,
  },
  rules: {
    // ... your rules
    "@typescript-eslint/no-unsafe-call": "error",
  },
};

By pointing to all project files, you empower ESLint to understand the cross-package references and correctly resolve your generic types from njihov source, thanks to the declarationMap you enabled in Step 1.

Step 4: The Last Resort - A Well-Placed Type Guard

If you've followed all the steps above and are still facing issues (perhaps due to a very complex third-party library or a limitation in the tooling), you can use a type guard as a targeted escape hatch. This is a manual assertion that tells TypeScript, "Trust me, I know what this is."

Use this sparingly, as it overrides the very safety you're trying to achieve.

// packages/app/src/index.ts (with a type guard)

import { createProcessor } from "@my-org/utils";

// ... User interface, etc.

const processUser = createProcessor<User>((user) => {
  console.log(`Handling user: ${user.name}`);
});

// Type Guard function
function isFunction(value: unknown): value is (...args: any[]) => any {
  return typeof value === 'function';
}

const myUser: User = { id: "1", name: "Alice" };

if (isFunction(processUser)) {
  // Inside this block, TypeScript knows `processUser` is a function.
  processUser(myUser); // No more ESLint error!
}

Again, this is a workaround, not a fix. The first three steps solve the underlying configuration problem. Reserve this for true edge cases.

Your Troubleshooting Checklist

Next time you hit this error, run through this list:

  • 1. Check `tsconfig.json`: Does your shared package have composite: true, declaration: true, and declarationMap: true?
  • 2. Verify Build Pipeline: Are you sure your dependencies are being built before the dependent package is linted? Check your monorepo tool's configuration.
  • 3. Inspect `.eslintrc.js`: Is parserOptions.project correctly pointing to all relevant tsconfig.json files in your monorepo?
  • 4. Restart the TS Server: The classic "turn it off and on again." In VS Code, open the command palette (Cmd+Shift+P) and run TypeScript: Restart TS Server. This forces it to re-evaluate your newly configured project.

Conclusion: From Frustration to Fluent Typing

The no-unsafe-call error in a monorepo isn't a bug; it's a symptom of a misaligned toolchain. It’s a sign that the flow of type information between your packages has a broken link. By methodically checking your TypeScript configuration, build process, and ESLint settings, you can repair that link.

Once everything is correctly configured, you'll find that types flow seamlessly across your entire monorepo. Your generics will be understood, your calls will be safe, and you can get back to writing robust, maintainable, and wonderfully type-safe code.

Tags

You May Also Like