Optimizing TypeScript Performance in Large Projects
AI generated
<T>
type
TypeScript · Performance · Compiler · Tooling
Optimizing TypeScript Performance in Large Projects
From generateTrace to project references

Growing a TypeScript codebase without watching compiler performance eventually means multi-minute build times and an editor tsserver that visibly stalls. Combinatorial type instantiation, oversized union types, and missing project references are the most common causes. This article shows hands-on how to diagnose slow builds with generateTrace, spot typical traps, and speed things up deliberately with project references and incremental builds.

17 min. read tsc · generateTrace · Project References TypeScript 5.x · Node.js · Monorepo

1. Why type-checking performance doesn't scale linearly with codebase size

The TypeScript compiler doesn't check each file in isolation. It builds a complete program graph out of every module, import, and type relationship. Any new file can potentially connect to many others through shared types, which means check time doesn't grow proportionally with project size, but often quadratically or worse. A project with twice as many lines rarely takes only twice as long to check.

It gets especially critical when generic types are instantiated with many different type arguments: each combination produces its own structurally-checked type instance, and these instances multiply combinatorially once several generic layers stack on top of each other. A single, innocent-looking utility type in a shared library can end up being re-instantiated at hundreds of call sites at once, noticeably extending the build time of the entire project.

In practice this shows up first on the CI server: a build that took two minutes six months ago suddenly takes twelve. It's rarely a single large refactor that causes this, but the sum of many small generic abstractions that accumulated over time.

2. Diagnosing slow builds: --generateTrace and --extendedDiagnostics

Before optimizing, you have to measure where the time actually goes. tsc --generateTrace trace-output writes a detailed event trace in the Chrome tracing format, which can be loaded directly into chrome://tracing or its Edge equivalent. The trace shows how long parsing, binding, and type-checking took per file, revealing which individual files consume disproportionate amounts of time instead of just handing you one aggregate number.

The official @typescript/analyze-trace tool automatically evaluates that same trace and lists the most expensive type checks directly in the console, including file name, line number, and time spent. For a quick first impression without a full trace, tsc --extendedDiagnostics is enough: among other things it reports the number of files checked, the number of type instantiations, and raw check time as plain numbers that compare well across commits.


#!/usr/bin/env bash
# Generate a compiler trace for the whole program
npx tsc --generateTrace trace-output -p tsconfig.json

# Install and run the official trace analyzer against the generated trace
npm install --no-save @typescript/analyze-trace
npx analyze-trace trace-output

# trace-output/trace.json can also be loaded manually in chrome://tracing

# Quick numeric stats without a full trace: check time, instantiation count
npx tsc --noEmit --extendedDiagnostics -p tsconfig.json

# Typical extendedDiagnostics output:
#   Files:                        842
#   Types:                     58211
#   Instantiations:           301744
#   Check time:                 9.87s

3. Extreme type instantiation: when TypeScript chokes on itself

One particularly stubborn performance problem is recursive conditional types without a clean base case. Every recursive call produces a new type instance, and without a termination condition that limits recursion depth, the number of instances explodes on deeply nested input types. TypeScript eventually aborts such chains with the error Type instantiation is excessively deep and possibly infinite, often on types that look harmless in practice.

Template literal types that combine several union types are similarly dangerous: a template with three placeholders, each with ten variants, already produces a thousand concrete string literal types that the compiler has to keep track of individually. The fix is almost always the same: bound the recursion with a depth counter that simply returns the accumulated result once it hits zero, instead of recursing indefinitely.

The example below shows both variants side by side: the naive recursion, which hits its limits on deeply nested tuples, and the depth-limited version, which produces the same result without overwhelming the compiler.


// Naive recursive conditional type: no depth limit and no base case
// for the recursion, risks "Type instantiation is excessively deep
// and possibly infinite" (TS2589) on deeply nested input types
type DeepFlatten<T> = T extends readonly [infer Head, ...infer Tail]
  ? Head extends readonly unknown[]
    ? [...DeepFlatten<Head>, ...DeepFlatten<Tail>]
    : [Head, ...DeepFlatten<Tail>]
  : [];

// Fails on large or very deeply nested tuples:
// type Huge = DeepFlatten<[[[[[[[[[[1]]]]]]]]]]>; // excessively deep

// Safer version: depth-limited recursion using a lookup tuple that
// decrements a counter type instead of recursing without a bound
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8];

type DeepFlattenLimited<T, D extends number = 8> = D extends 0
  ? T
  : T extends readonly [infer Head, ...infer Tail]
    ? Head extends readonly unknown[]
      ? [...DeepFlattenLimited<Head, Prev[D]>, ...DeepFlattenLimited<Tail, D>]
      : [Head, ...DeepFlattenLimited<Tail, D>]
    : [];

// Terminates deterministically after at most 8 levels of recursion
type Safe = DeepFlattenLimited<[[[[1]]]]>;

4. More performance killers: nested types, huge unions, generic abstractions

Instantiation depth isn't the only performance killer. Deeply nested conditional types that are really just meant to express a simple case distinction force the compiler into repeated structural comparisons, even though a single mapped type or a plain lookup would achieve the same effect with a fraction of the cost. Just as expensive are discriminated unions with hundreds of members evaluated inside a single exhaustive switch: every branch forces the compiler to narrow the entire union all over again.

A third, often overlooked case is overly generic utility-type abstractions stacked across several layers. Every additional generic layer gets recomputed at every call site, since TypeScript doesn't cache types project-wide but instantiates them per usage site. A library with five nested generic helper types can end up being re-evaluated thousands of times across thousands of call sites in a project, without any single call looking suspicious on its own.

The example below shows a Redux-style action union with hundreds of variants and its leaner alternative, which derives the same type safety from a single payload map.


// Bloated: hundreds of near-identical members inflate the union type,
// and every access forces the checker to narrow across all of them
type Action =
  | { type: "user/create"; payload: { name: string; email: string } }
  | { type: "user/update"; payload: { id: string; name: string } }
  | { type: "user/delete"; payload: { id: string } }
  // ...200+ more variants follow the exact same shape per domain
  | { type: "order/create"; payload: { sku: string; qty: number } };

function reduce(action: Action) {
  switch (action.type) {
    case "user/create": return handleUserCreate(action.payload);
    case "user/update": return handleUserUpdate(action.payload);
    // ...200+ more case labels, each re-narrowing the full union
    default: return action;
  }
}

// Leaner: derive the union from a single payload map instead of
// hand-writing every member, the checker only narrows one lookup
interface ActionPayloadMap {
  "user/create": { name: string; email: string };
  "user/update": { id: string; name: string };
  "user/delete": { id: string };
  "order/create": { sku: string; qty: number };
}

type LeanAction = {
  [K in keyof ActionPayloadMap]: { type: K; payload: ActionPayloadMap[K] };
}[keyof ActionPayloadMap];

const handlers: { [K in keyof ActionPayloadMap]: (p: ActionPayloadMap[K]) => void } = {
  "user/create": handleUserCreate,
  "user/update": handleUserUpdate,
  "user/delete": handleUserDelete,
  "order/create": handleOrderCreate,
};

function reduceLean(action: LeanAction) {
  return handlers[action.type](action.payload as never);
}

5. Project references: cleanly splitting large codebases

Project references are the most important structural tool against growing build times in large codebases. Instead of checking a single, monolithic tsconfig.json across the entire repository, the project is split into independent sub-projects marked with composite: true, which point to each other via references. Each sub-project is checked and compiled individually, produces its own declaration files, and the compiler only needs to know the public .d.ts signatures of dependent packages, not their full internal source.

The decisive advantage shows up on rebuilds: with tsc --build, the compiler walks the reference graph and only rebuilds the projects whose source or dependencies actually changed since the last run. In a monorepo with ten packages, a small change in a leaf package no longer triggers a full rebuild, only a re-check of that one package and the packages that depend on it directly.


// tsconfig.json (solution file at the monorepo root, no source files)
{
  "files": [],
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/api-client" },
    { "path": "./packages/web" }
  ]
}

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "target": "ES2022",
    "module": "ESNext",
    "strict": true
  },
  "include": ["src"]
}

// packages/web/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../core" },
    { "path": "../api-client" }
  ],
  "include": ["src"]
}

6. Incremental builds and skipLibCheck as a pragmatic fix

Incremental builds add a second layer of reuse on top of project references: with incremental: true, the compiler writes a .tsbuildinfo file after every run that caches file hashes, program structure, and diagnostics. On the next invocation, tsc compares the current files against this snapshot and only checks what has changed since, instead of re-analyzing the entire program from scratch.

skipLibCheck: true is the most pragmatic single measure with the biggest leverage: it skips the full type check of every .d.ts file from node_modules, which in large projects is often several hundred files whose types have already been checked by the respective package authors. The trade-off is small, since incompatibilities between two library declarations rarely have anything to do with your own code anyway, and the time saved on projects with many dependencies is substantial.

Combining both options frequently cuts repeated build times by more than half in practice, without changing a single line of actual code.


{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "./dist/.tsbuildinfo",
    "skipLibCheck": true,
    "composite": true,
    "declaration": true,
    "target": "ES2022",
    "module": "ESNext",
    "strict": true
  },
  "include": ["src"]
}

// First run: full check, writes ./dist/.tsbuildinfo
// Subsequent runs: tsc reuses the cached program state and only
// re-checks files whose content or dependencies actually changed

7. Editor performance: why tsserver stalls in large projects

Build performance is only half the story: in the editor, tsserver checks types continuously while you type, and in large projects that first shows up as delayed autocomplete, sluggish hover, and ballooning memory usage. Unlike a CI build, tsserver runs permanently in the background and has to decide, on every keystroke, which part of the program graph is affected.

A common, easily fixable mistake is an overly broad include pattern that accidentally pulls generated files, build output, or even parts of node_modules into the language service. Project references help here too: tsserver only loads the relevant sub-project per open file instead of the entire repository. For very large monorepos, raising typescript.tsserver.maxTsServerMemory in VS Code additionally helps avoid crashes caused by running out of memory.

Anyone who regularly sees a single tsserver process using several gigabytes of memory has almost always got a project loaded into the language service that's either too large or poorly scoped.

8. Build orchestration: caching and parallelization in monorepos

In monorepos with multiple teams, project references alone often aren't enough: build orchestration tools like Nx or Turborepo build a dependency graph across all packages and cache the output of every build step, including the generated .tsbuildinfo files. If a package hasn't changed, the cached output is reused directly, both locally and in the CI pipeline, without tsc even being invoked again.

Remote caching goes a step further: build output lands in a shared cache server, so a colleague checking out the same commit downloads the already-checked state instead of rebuilding locally. For CI pipelines, this means consistently caching node_modules and the .tsbuildinfo files keyed by the lockfile hash, so a pure documentation or CSS commit doesn't trigger a full TypeScript check.

Parallelizing across the reference graph is the last piece: independent sub-projects can be checked simultaneously, as long as the build order respects the actual dependencies.

9. Slow vs. performance-conscious TypeScript patterns compared

The following five patterns keep showing up in growing TypeScript projects, and nearly all of them can be replaced with a targeted, often small change. The difference is rarely stylistic; it has a direct effect on build time and memory usage, especially in large monorepos with many packages.

Scenario Performance-costly pattern Performance-conscious alternative Effect
Recursive types DeepFlatten<T> without a base case Depth-limited recursion with a counter No more TS2589 on deep nesting
Project structure One monolithic tsconfig.json Project references with composite: true Only changed sub-projects get re-checked
Checking node_modules Full check of every .d.ts file skipLibCheck: true Noticeably shorter check time with many deps
Discriminated union 200+ hand-written union members Derive the union from a payload map Less narrowing work per switch
CI build Full rebuild on every commit incremental + cached .tsbuildinfo Only changed files get re-analyzed

All five optimizations can be introduced independently and add up in practice: projects that consistently apply all five patterns report build-time reductions between 40 and 80 percent, depending on the starting point and project size.

Mironsoft

TypeScript tooling, build performance, and monorepo setup

Ready for TypeScript builds that don't hold you back?

We analyze your TypeScript codebase with generateTrace and extendedDiagnostics, identify the most expensive type instantiations, and set up project references and incremental builds for your monorepo stack.

Performance audit

Trace analysis and identification of the most expensive type instantiations

Build refactoring

Project references, composite packages, and leaner type abstractions

CI caching setup

Caching .tsbuildinfo and node_modules deliberately in the pipeline

10. Summary

TypeScript performance in large projects depends on a handful of decisive levers: diagnose before you optimize with generateTrace and extendedDiagnostics, deliberately bound instantiation depth on recursive types, keep discriminated unions lean instead of hand-writing hundreds of variants, and split the codebase structurally with project references. Working through these four points in this order fixes the biggest cost drivers first, instead of papering over symptoms one at a time.

Incremental builds with .tsbuildinfo, skipLibCheck, and a working cache strategy in the CI pipeline are the pragmatic second step, one that can be introduced with almost no risk. Combined with a deliberate project structure for tsserver, TypeScript stays a productive tool rather than a daily waiting game, even at tens of thousands of lines of code across multiple teams.

Optimizing TypeScript Performance in Large Projects: The Essentials at a Glance

Diagnose first

tsc --generateTrace and --extendedDiagnostics before any optimization, to find the actual cost drivers.

Bound instantiation depth

Always give recursive conditional types a depth counter or a clear base case.

Project references

Split large codebases into composite sub-projects, rebuild only changed packages.

Build incrementally

Cache .tsbuildinfo, enable skipLibCheck, establish a cache strategy in the CI pipeline.

11. FAQ: Optimizing TypeScript Performance in Large Projects

1Why doesn't TypeScript compile time scale linearly with project size?
The compiler builds a complete program graph out of every file and its type relationships. New files can connect to many existing ones, often making check time grow quadratically rather than linearly.
2What does tsc --generateTrace do and how do you read the trace file?
Writes an event trace in the Chrome tracing format, loadable in chrome://tracing. Shows the duration of parsing, binding, and type-checking per file.
3What is @typescript/analyze-trace used for?
Automatically evaluates a trace and lists the most expensive type checks directly in the console, including file, line, and time spent.
4What does tsc --extendedDiagnostics tell you?
Provides quick numeric metrics like file count, type instantiations, and check time, without generating a full trace.
5What does "excessively deep and possibly infinite" mean?
TypeScript aborts evaluation once instantiation depth crosses an internal limit. Most common cause: recursive types without a base case or depth limit.
6Why are huge discriminated unions a performance problem?
Every branch in an exhaustive switch forces the compiler to narrow the entire union again. A union derived from a payload map significantly reduces that cost.
7What do project references actually give you?
Split a repository into independent composite sub-projects. tsc --build only rebuilds the projects whose code has changed.
8What is skipLibCheck for and is it safe?
Skips the full type check of .d.ts files from node_modules. Safe in practice, since those types have already been checked by the package authors.
9How does incremental work with .tsbuildinfo?
The compiler writes file hashes and program structure into a .tsbuildinfo file, and only checks what has changed since on the next run.
10Why does the editor (tsserver) get slow in large projects?
tsserver checks types continuously while you type and often loads more files than necessary. Project references and a tighter include noticeably limit the amount of code loaded.