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.
Table of Contents
- 1. Why type-checking performance doesn't scale linearly with codebase size
- 2. Diagnosing slow builds: --generateTrace and --extendedDiagnostics
- 3. Extreme type instantiation: when TypeScript chokes on itself
- 4. More performance killers: nested types, huge unions, generic abstractions
- 5. Project references: cleanly splitting large codebases
- 6. Incremental builds and skipLibCheck as a pragmatic fix
- 7. Editor performance: why tsserver stalls in large projects
- 8. Build orchestration: caching and parallelization in monorepos
- 9. Slow vs. performance-conscious TypeScript patterns compared
- 10. Summary
- 11. FAQ
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.