Skip to content

09 · Performance & Compilation

TypeScript's compiler is doing real work — parsing, binding, and a type checker that can recursively expand generic types — and on a large project that work is visible as slow tsc runs and a laggy editor. This module uses --extendedDiagnostics to actually measure where the time goes and shows the two biggest levers for speeding it up.

Measuring with --extendedDiagnostics

// heavy.ts
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;

interface Big {
  a: { b: { c: { d: { e: number; f: string } } } };
}

const patch: DeepPartial<Big> = { a: { b: { c: { d: { e: 1 } } } } };
console.log(patch);
tsc --noEmit --extendedDiagnostics heavy.ts
Files:              83
Lines:           58456
Identifiers:     49746
Symbols:         59809
Types:           35374
Instantiations:  34321
Memory used:    64902K
Check time:     0.132s
Total time:     0.158s

Notice 83 files and 58,456 lines for one small .ts file — that's lib.d.ts (the built-in JS/DOM type definitions) being pulled in and fully type-checked by default. Check time (0.132s) dominates Total time here, and check time is what scales with the number and complexity of types your code touches, not raw line count of your code.

skipLibCheck: the single biggest lever

tsc --noEmit --extendedDiagnostics --skipLibCheck heavy.ts
Symbols:         32873
Types:             395
Instantiations:     91
Memory used:    25623K
Check time:     0.001s
Total time:     0.026s

Same file, same output — but Check time drops from 0.132s to 0.001s and Types drops from 35,374 to 395. skipLibCheck tells tsc to trust that .d.ts files (both lib.d.ts and everything in node_modules/@types) are already valid and skip re-checking them — your own code is still fully checked. This is why skipLibCheck: true is in nearly every real-world tsconfig.json: it costs you almost nothing in safety (you're not going to fix a bug in lib.d.ts anyway) and buys back most of the checker's time on any project with a non-trivial node_modules.

Incremental builds

tsconfig.json
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": "./.tsbuildinfo"
  }
}

With incremental: true, tsc writes a .tsbuildinfo file recording what it checked; the next run reads it and only re-checks files that changed (or that depend on a changed file) instead of the whole program. For editor responsiveness, the TypeScript language server does this automatically — .tsbuildinfo matters specifically for repeated CLI/CI invocations of tsc.

Project references for large monorepos

tsconfig.json (root)
{
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/api" }
  ]
}
tsc --build

Project references split a large codebase into smaller, independently type-checked units with declared dependencies between them. tsc --build only rebuilds a package (and its dependents) when that package's own files change — a change deep in packages/api doesn't force re-checking packages/core if core doesn't depend on api. This is the mechanism, not just a convention: tsc enforces the reference graph and refuses circular references.

Traps

Recursive conditional/mapped types like DeepPartial<T> cost more the deeper the type nests, and TypeScript enforces a hard recursion depth limit — past it you get error TS2589: Type instantiation is excessively deep and possibly infinite, even for a type that would terminate given enough steps. Deeply generic utility types are a common source of this in real codebases that lean hard on type-level programming (see the Level 4 module on advanced type-level programming).

skipLibCheck doesn't skip checking your own .d.ts files against your own code that consumes them — it skips checking the internal correctness of library .d.ts files. If your code misuses a type from a library, that error still surfaces; skipLibCheck only stops tsc from also verifying the library's declaration file is internally self-consistent.

Turning on incremental without gitignoring .tsbuildinfo pollutes diffs with a binary-ish cache file that changes on every build — add it to .gitignore.

Editor slowness and tsc CLI slowness have different causes. The language server (what your editor uses) does incremental, single-file reanalysis by default regardless of your tsconfig; a slow editor is more often caused by an enormous single file, a very large union type, or a plugin, not by the same things that make a cold tsc --build slow.

How It Actually Works

tsc's incremental mode (incremental: true, backed by a .tsbuildinfo file) avoids re-checking your whole program on every compile by persisting a per-file signature — essentially a hash of each file's exported, publicly-visible type shape — across runs; on a subsequent build, the checker only needs to fully re-verify files whose own source changed or whose signature (not just source text) of an imported file has changed, because a change that doesn't affect a file's externally visible type shape (a renamed local variable, a comment) can't possibly affect how any file importing it type-checks. This is the mechanical reason incremental builds are so much faster on large codebases: the expensive structural-comparison work is skipped entirely for files provably unaffected by what changed.

Project references (composite: true, references: [...] in tsconfig.json) extend this idea across an entire monorepo by splitting the single, whole-program type graph the checker would otherwise build into separate, independently-checked sub-graphs joined only through their emitted .d.ts boundaries — a downstream project only ever type-checks against an upstream project's declaration output, never re-walking the upstream project's actual implementation source, which is what lets tsc --build skip recompiling an entire unchanged package rather than re-verifying it as part of one giant program.

Deeply recursive conditional/mapped types (the kind covered in advanced generics) impose a real, measurable cost distinct from ordinary structural checking: the checker enforces an internal instantiation-depth limit specifically because unbounded recursive type computation is undecidable in the general case, and even within that limit, evaluating a highly recursive type is genuine tree-walking work performed on every file that references it — this is why a single overly-clever recursive utility type in a widely-imported module can measurably slow down an entire project's build, in a way no amount of incremental caching fully hides, because the recursive type itself still has to be resolved wherever it's used.

Cheat sheet

Setting/flag Effect
--extendedDiagnostics Prints parse/bind/check/emit timing breakdown
skipLibCheck: true Skips re-checking .d.ts files; usually the biggest win
incremental: true Caches check results in .tsbuildinfo between CLI runs
Project references + tsc --build Per-package incremental checking in a monorepo
TS2589 Recursion depth limit hit on a conditional/mapped type

Exercise

Take the DeepPartial<T> example, nest Big two more levels deeper, and re-run tsc --noEmit --extendedDiagnostics both with and without --skipLibCheck. Record the four Check time numbers (with/without, shallow/deep) in a short table and note which factor — lib checking or nesting depth — moved the number more.