Back to monorepo orchestration Target affected workspaces Configure turbo pipelines Speed up type-checking

Speeding Up Type-Checking in Large Monorepos

In a large TypeScript monorepo, type-checking is often the slowest step in CI and the reason editors lag: a single tsc program can include tens of thousands of files, re-check the same shared code from several applications, and repeat all of it on every run. The fixes are structural rather than magical — check each file once, skip what has not changed, share results between machines, and remove configuration that makes the compiler do extra work. This guide measures where time goes, then applies the techniques that help most, in the order that usually pays off.

Measure first

Before changing anything, find out what the compiler is doing:

# Overall timing and program size for one project
npx tsc -p apps/web --noEmit --extendedDiagnostics
Files:                         12,418
Lines of TypeScript:          842,113
Lines of Definitions:         611,977
Memory used:                 2,812,441K
Check time:                      38.12s
Total time:                      47.90s

Two numbers matter most. Files and lines tell you how much the program contains — if an application's program includes the source of every shared package, it is re-checking code other programs also check. Check time versus total time tells you whether the cost is type-checking itself (complex types, many files) or I/O and parsing.

For deeper analysis, generate a trace and inspect it:

npx tsc -p apps/web --noEmit --generateTrace ./trace
npx @typescript/analyze-trace ./trace

The analyser lists the files and expressions that took longest to check — often a handful of complex generic types or a giant generated file. The broader setup is covered in TypeScript Project References in Monorepos.

Where check time goes in an example repository Example breakdown of type-check time across re-checking shared package source, generated API types, complex generic utilities and application code. shared packages re-checked per app 46% generated API client types 21% a few complex generic utilities 14% application code itself 19%
In many repositories, most time goes to checking the same shared code repeatedly and to a few pathological types.

Technique 1: check each file once with project references

If each application's program includes shared packages' source, every application re-checks them. Project references make each package a separate project whose declarations dependents read instead. Shared code is checked once, in its own project, and consumers only check how they use it. The configuration is in Configuring tsc --build with Composite Projects.

For repositories that prefer source-only internal packages, the hybrid approach — compiled declarations for type-checking, source for dev servers — gets the same effect, as described in Using Internal Packages Without a Build Step.

Technique 2: skip unchanged work

tsc -b records build info per project and skips projects whose inputs have not changed. In CI, persist that state between runs — cache the dist/ folders containing .tsbuildinfo and declarations, or cache a per-package typecheck task with a task runner:

{
  "tasks": {
    "typecheck": {
      "dependsOn": ["^typecheck"],
      "inputs": ["src/**/*.{ts,tsx}", "tsconfig.json", "$TURBO_ROOT$/tsconfig.base.json"],
      "outputs": ["dist/**/*.d.ts", "dist/.tsbuildinfo"]
    }
  }
}

With a remote cache, a pull request that touches one package type-checks that package and its dependents; everything else is a cache hit. Combined with affected-only runs, this is usually the single biggest improvement.

Techniques in order of typical payoff Five techniques from project references and caching, through affected runs and removing pathological types, to compiler options, ordered by typical impact. 1 Check each file once project references or hybrid packages remove repeated checking of shared code 2 Skip unchanged projects tsc -b build info plus task-runner and remote caching 3 Only affected projects run type-checks for changed packages and their dependents 4 Fix pathological types trace-guided: simplify deep generics, split generated files 5 Tune compiler options skipLibCheck, isolatedDeclarations, fewer global types
Structural changes pay off most; compiler flags are the last and smallest lever.

Technique 3: run only what a change affects

Type-check only the packages affected by a pull request, plus their dependents:

pnpm turbo run typecheck --filter="...[origin/main]"
# or
pnpm nx affected -t typecheck

Keep a full type-check on main (cached, so usually fast) as a backstop. Affected detection is covered in Fixing Slow Monorepo CI with Affected Builds.

Technique 4: fix pathological types and files

Traces often reveal a few culprits:

  • Deeply recursive or heavily conditional generic types — type-level parsers, deep path types for form libraries, large union manipulations. Simplify them, add explicit type annotations at boundaries so inference does not re-run, or cap recursion depth.
  • Huge generated files — API clients generated from large schemas, GraphQL types. Split them per endpoint or domain so each consumer only includes what it imports, and make sure they are generated as .d.ts plus lightweight runtime code rather than one enormous .ts file.
  • Barrel files that re-export everythingexport * from './everything' forces the compiler to process every module behind the barrel for every import. Import from specific entry points in hot paths, or keep barrels shallow.
  • Implicit return types on exported functions — the compiler must infer them for every consumer's check; explicit return types cut that work and are required anyway for isolatedDeclarations.

Technique 5: compiler options that help

{
  "compilerOptions": {
    "skipLibCheck": true,
    "isolatedDeclarations": true,
    "types": [],
    "incremental": true
  }
}
  • skipLibCheck skips checking .d.ts files, including those in node_modules. It is safe for most application code and saves noticeable time in repositories with many dependencies.
  • isolatedDeclarations (TypeScript 5.5+) requires explicit types on exports, which lets tools generate declarations without a full type-check and makes each project's declarations cheaper to produce and consume.
  • types: [] stops TypeScript from loading every @types/* package in node_modules automatically; list only the ones each project needs (["node"], ["vitest/globals"]). In monorepos with many @types packages hoisted to the root, this can remove thousands of files from every program.
  • incremental writes build info for non-composite projects, speeding up repeated local checks.

Splitting type-checking from builds

A common hidden cost is running the type-checker inside the build. Tools such as ts-loader without transpileOnly, rollup-plugin-typescript2, or tsc used as the JavaScript compiler type-check every time they build, so a monorepo that builds twelve packages also type-checks them twelve times, serially with each build. Switching builds to a transpile-only compiler — esbuild, SWC, tsup, or Vite's built-in transform — and running type-checking as its own task lets the two run in parallel and be cached separately.

Build-time type-checking versus a separate typecheck task Compares type-checking inside each build with a transpile-only build plus an independent typecheck task on parallelism, caching and failure visibility. type-check inside build transpile build + typecheck task Build duration includes full check transpile only Runs in parallel serial with build independent task Cached separately one cache entry two, finer-grained Type errors block builds always via required CI check
Separating the two lets builds finish fast and type-checks run once, in parallel, with their own cache.

The trade-off is that a build can succeed while type errors exist, so the typecheck task must be a required status check in CI. In practice that is what teams want anyway: build output is available sooner for tests and previews, and type errors are reported by a dedicated, clearly named job rather than buried in bundler output.

Keeping the gains

Type-check performance tends to regress quietly as code grows: a new generated file, a clever generic type, or an extra global @types package each add a little. Record --extendedDiagnostics output for a representative project in CI (file count, check time, memory) and alert when it grows by more than a threshold. Treat regressions like performance bugs — trace, find the culprit, and decide whether the added cost is worth it. A few minutes spent on each regression when it happens is far cheaper than a quarter-long effort to recover from a type-check that has slowly doubled.

Editors

Editor responsiveness follows the same principles. With project references, the language service loads the project containing the open file plus what it needs, not the whole repository. For very large repositories, disableReferencedProjectLoad and disableSolutionSearching reduce what the editor loads eagerly. And the newer native TypeScript compiler preview (TypeScript 7, written in Go) promises large speedups for both command line and editor; it is worth testing on a branch as it matures, since configuration carries over.

Worked example: 14 minutes to 3

A repository with 120 packages type-checked every package with tsc --noEmit in parallel jobs, taking 14 minutes per pull request. Tracing showed that three applications each re-checked a 90,000-line design system and a generated API client. The team made libraries composite with references, split the generated client by domain, set types: [] per project, and cached per-package typecheck tasks remotely with affected filtering. Typical pull requests now type-check in about three minutes, and the full cached run on main in under five.

Prevention and guardrails

  • Track type-check time in CI and investigate regressions with --extendedDiagnostics.
  • Keep generated types split and small.
  • Require explicit return types on exports (or isolatedDeclarations) in shared packages.
  • Review new global @types packages, which affect every program.

Frequently Asked Questions

Is skipLibCheck unsafe? It skips checking declaration files for internal consistency, not your use of them. Errors inside third-party .d.ts files go unreported, which is usually what you want. Keep it off only if you publish declarations and want them checked in CI.

Does using SWC or esbuild speed up type-checking? No. They strip types without checking them. They speed up builds, which lets you run type-checking as a separate, parallel, cacheable task — a real improvement, but a different one.

How do I know which packages dominate check time? Time each package's typecheck task (task runners print per-task durations) and use --generateTrace on the slowest.

Should I split one huge application into several TypeScript projects? Sometimes. If an application contains largely independent areas, splitting it into composite sub-projects with references lets tsc -b skip unchanged areas. Only do it along real module boundaries, or you will fight circular references.

Related

TypeScript Project References in Monorepos