# Build performance: the TypeScript bottleneck nobody profiles

> Source: https://learn-typescript.org/review/performance/
> Part of Learn TypeScript, free to read.

The runtime performance of your compiled output is a JavaScript question, and [the JavaScript performance page](https://learn-javascript.org/review/performance/) covers it — event loop blocking, N+1 queries, unbounded concurrency, memory leaks. All of it applies unchanged.

TypeScript's own performance problem is different and specific: **`tsc` gets slow, and a slow `tsc` is not just an annoyance — it breaks the agent feedback loop.**

## Why compile time is a code-quality issue

The threshold effect from [the verification loop](https://learn-python.com/ai/feedback-loops/) applies to type checking exactly as it does to tests:

| `tsc --noEmit` takes | What the agent does |
|---|---|
| under 2s | runs it after every edit. converges. |
| 2–10s | runs it after a few edits. usually fine. |
| 10–60s | runs it once at the end. you review guesses. |
| over 60s | stops running it. tells you it "should compile". |

Nobody instructs an agent to skip a slow check; it just happens. So the type system — the thing that makes TypeScript worth using with generated code — quietly stops being part of the loop.

**If your project takes 90 seconds to type check, fixing that is a bigger quality win than any prompt change.**

## Find out where the time goes

```bash
npx tsc --noEmit --diagnostics
```

```text
Files:                         1842
Lines of TypeScript:         184203
Types:                       412991      <- watch this
Instantiations:            14882031      <- and especially this
Memory used:                1284120K
Check time:                   38.42s
```

`Instantiations` is the number that matters. Under a million is healthy. Over ten million means a small number of types are exploding combinatorially, and finding them is usually a one-afternoon fix with a dramatic payoff.

```bash
npx tsc --noEmit --generateTrace ./trace
npx @typescript/analyze-trace ./trace      # ranks the worst files and types
```

`analyze-trace` names the specific file and the specific type expression. It is the profiler for this problem and almost nobody knows it exists.

## What makes instantiations explode

### 1. Deeply recursive conditional and mapped types

```ts
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
type Paths<T> = T extends object
  ? { [K in keyof T]: `${K & string}` | `${K & string}.${Paths<T[K]>}` }[keyof T]
  : never;
```

`Paths` over a large nested type generates a combinatorial number of string literal types. One of these applied to a big config object can account for most of your check time on its own.

Generated code produces these because they are impressive and they appear in a lot of type-gymnastics blog posts. Ask whether a plain `string` with a runtime check would do — usually it would.

### 2. Very large union types

A union of a few hundred string literals is fine. A union of tens of thousands — generated from a route table, an icon set, a translation key list — makes every operation touching it expensive, and it degrades editor responsiveness as well as the build.

### 3. Inferred return types on large functions

```ts
export function buildConfig() { return { /* 200 lines of nested object */ }; }
```

Every consumer re-infers that type. An explicit return type annotation computes it once.

This is what `isolatedDeclarations` enforces:

```json
{ "compilerOptions": { "isolatedDeclarations": true } }
```

It requires explicit types on all exports. More typing, and it makes declaration emit dramatically faster and parallelisable — worth it for a library or a monorepo package.

### 4. Barrel files

```ts src/index.ts
export * from "./a";  export * from "./b";  /* … 60 more */
```

Importing one symbol pulls the whole graph into checking. Barrels are convenient and they are a leading cause of slow builds and slow editors in large projects. Import from the specific module.

### 5. Heavy library types

Some libraries have famously expensive types — deeply generic ORM query builders and validation libraries with elaborate inference. Usually worth it, but if `analyze-trace` points at one, that is your answer, and sometimes an explicit annotation at the call site short-circuits the inference.

## The structural fixes

### Incremental builds

```json
{ "compilerOptions": { "incremental": true, "tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo" } }
```

Free, and it turns a full check into a check of what changed. Put the buildinfo file somewhere gitignored and cache it in CI.

### Project references

For a monorepo, this is the big one:

```json tsconfig.json
{
  "files": [],
  "references": [{ "path": "./packages/shared" }, { "path": "./packages/api" }]
}
```

```bash
tsc --build          # only rebuilds what changed, in dependency order
tsc --build --watch
```

Each package is checked once and its output reused, rather than every package re-checking shared code. On a large monorepo this is often a 5–10x improvement.

### Separate the type check from the transpile

```json package.json
{
  "scripts": {
    "build": "tsup src/index.ts",     // esbuild/swc: fast, strips types, no checking
    "typecheck": "tsc --noEmit"       // the slow part, run separately and in CI
  }
}
```

Transpilers do not type check, which is exactly why they are fast. Use them for the build and dev server; keep `tsc --noEmit` as the correctness gate. The agent's edit hook should run `tsc` on the changed file's project, not the whole monorepo.

### Narrow what is checked

```json
{ "include": ["src/**/*"], "exclude": ["**/*.test.ts", "dist", "node_modules"] }
```

And keep `skipLibCheck: true` — checking every `.d.ts` in `node_modules` is pure cost for almost no benefit.

## A budget worth setting

```bash
# fail CI if the check gets slower than the loop can tolerate
time npx tsc --noEmit
npx tsc --noEmit --diagnostics | grep -E 'Instantiations|Check time'
```

Treat check time like bundle size: measure it, put a ceiling on it, and investigate when it moves. A project that drifts from 3 seconds to 40 does so one clever type at a time, and nobody notices until the loop is broken.

:::verdict The TypeScript-specific one
Your runtime performance problems are JavaScript's. Your *build* performance problem is TypeScript's, it is measurable with two commands, and it matters more than usual now — because when type checking falls out of the edit loop, the type system stops constraining generated code at the moment it is being written, which was the whole reason to use TypeScript with an agent.
:::

## Common questions

### Should I use a Rust-based checker instead of `tsc`?

Faster alternative type checkers are maturing and worth watching. Today, `tsc` remains the reference implementation and the only thing guaranteed to agree with the language semantics. Use a fast transpiler for the build and `tsc` for the check; revisit when an alternative is bit-compatible enough that you would trust it as your CI gate.

### Is `isolatedDeclarations` worth adopting?

For a published library or a monorepo package that others depend on, yes — it makes declaration emit fast and parallelisable, and the explicit return types are good documentation. For an application with no consumers, the extra annotations cost more than they return.

### How slow is too slow?

Two seconds is where the agent stops noticing; ten is where it starts skipping. Under five seconds for the package you are editing is a good target, which is achievable on almost any codebase with incremental builds and project references.

### Why did my build get slow after adding one library?

Usually a deeply generic inference chain from an ORM or validation library, sometimes combined with a barrel file that pulls in the whole thing. `--generateTrace` plus `analyze-trace` will name the file and the type in a couple of minutes.
