TypeScript Performance

October 5, 20268 min read
Requirements:
FunctionsObjectsGenerics

TypeScript performance is almost always a type-checking problem, not a build problem. Your bundler is fine. What got slow is tsc grinding through types your code asks it to compute, and the editor doing the same work again every time you type. This page is about finding which types cost you and what to change.

The order matters here. Measure first, then cut the expensive types, then reach for compiler flags. Doing it the other way around is how people end up with skipLibCheck on and a build that is still slow.

Measure before you change anything

The compiler will tell you where the time goes. Start with the summary:

[object Object]

You get a block of numbers. Four of them matter:

Check time high, instantiation count high? Keep reading the next section. Parse time high and check time low? Skip ahead to include and exclude — you are feeding the compiler files it does not need.

For the detailed view, generate a trace:

[object Object]

That writes trace.json and types.json into trace-out. Load trace.json in chrome://tracing or Perfetto and you get a flame chart of checkSourceFile spans. The widest span is the file to look at. @typescript/analyze-trace turns the same file into a sorted text list if you would rather not click around:

[object Object]

Both tools point at a file and a line. That is usually enough to find the offending type by eye.

Where the time actually goes

Three patterns account for most slow projects.

Union size multiplies

Unions are the single biggest driver of checker work, because most type-level operations run once per member. Template literal types turn that into multiplication:

type HttpMethod = 'get' | 'post' | 'put' | 'delete'
type ApiResource = 'user' | 'order' | 'invoice'
type ApiRegion = 'eu' | 'us' | 'ap'
 
// 4 × 3 × 3 = 36 string literals, all built eagerly
type RouteKey = `${ApiRegion}:${HttpMethod}:${ApiResource}`

Thirty-six is nothing. Add a fourth axis with twenty members and you are at 720, and every conditional type that touches RouteKey now runs 720 times. TypeScript caps unions at 100,000 members and errors out past that, which is the compiler telling you this was never going to be fast.

The fix is usually to stop encoding the combination in the type and validate it at runtime instead:

function parseRouteRegion(key: string): string {
  return key.slice(0, key.indexOf(':'))
}

You lose autocomplete on the full key. You get your build back. Decide which one you actually need — see union types for when the exhaustive version earns its cost.

Recursion that does not stop early

Recursive types are fine until they run over large objects on every check:

type DeepReadonlyNode<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonlyNode<T[K]> : T[K]
}

This re-instantiates for every nested property of every type it touches. One use is cheap. Applied to your whole API response type in fifty files, it is not. Mapped types over wide unions have the same shape of cost: the mapping runs per member, per use site.

If a recursive helper shows up in a trace, the cheapest fix is often to stop being generic. Write the concrete type out once and let the checker read it instead of deriving it.

Interfaces are cheaper than intersections

The checker treats these two very differently:

interface BaseEntity {
  id: string
  createdAt: Date
}
 
interface AuditFields {
  updatedBy: string
}
 
// ✅ resolved once into a single object type with a cached property list
interface AuditedEntity extends BaseEntity, AuditFields {
  deletedAt: Date | null
}
 
// ❌ both sides stay around; property lookups resolve through the intersection each time
type AuditedEntityUnion = BaseEntity & AuditFields & { deletedAt: Date | null }

interface extends builds one flat type with a cached member table, and errors point at the conflicting property. An intersection type keeps its parts and resolves on demand, which is slower and produces worse error messages. When both forms express the same thing — and for object types they usually do — pick the interface.

Annotate the boundaries

Inferred return types get recomputed and, if you emit declarations, written out in full. A single annotation at an exported boundary can remove a lot of work:

interface InvoiceRow {
  id: string
  cents: number
}
 
// ❌ the shape is inferred here and re-derived at every call site
function buildInvoiceRows(ids: string[]) {
  return ids.map((id) => ({ id, cents: 0 }))
}
 
// ✅ the checker already has the answer
function buildTypedInvoiceRows(ids: string[]): InvoiceRow[] {
  return ids.map((id) => ({ id, cents: 0 }))
}

This is the highest-value, lowest-risk change on the list. Annotate exported functions, leave local ones inferred.

The tsconfig levers that move TypeScript performance

Once the types are reasonable, configuration is worth real time. These live in tsconfig.json.

{
  "compilerOptions": {
    "skipLibCheck": true,
    "incremental": true,
    "tsBuildInfoFile": "./node_modules/.cache/tsconfig.tsbuildinfo"
  },
  "include": ["src"],
  "exclude": ["**/*.test.ts", "dist"]
}

skipLibCheck stops the compiler from type-checking every .d.ts in node_modules against itself. Those files are not your code and the conflicts between two versions of the same @types package are not your bug. On a project with a large dependency tree this is often the biggest single win. It does not skip the types — your usage is still checked.

incremental writes a .tsbuildinfo file so the next run only re-checks what changed. Point tsBuildInfoFile somewhere your CI cache picks up, or you get the cost of writing the file with none of the benefit.

Project references with composite: true let each package be checked once and consumed as .d.ts by the others, instead of every package re-checking the whole source tree. This is the structural fix for a slow monorepo, and the only one that scales.

include and exclude decide how much work exists at all. A stray include: ["."] pulls in test fixtures, build output, and scripts. If --extendedDiagnostics reports a file count much larger than you expected, this is why.

Two things are not on this list. isolatedModules and noEmit change what the compiler produces, not how long checking takes. And raising --max-old-space-size fixes a crash, not a slow build.

Editor slowness is a different problem

A cold tsc run and a sluggish editor are related but not the same. The language service re-checks the open file and its dependencies on every keystroke, from memory, with no incremental file on disk to help it.

If the editor is slow but the build is fine, check the TypeScript server itself. In VS Code, run TypeScript: Open TS Server Log, or open the TypeScript output channel and look for repeated full-project reloads. Common causes: a huge union being resolved for autocomplete, a .d.ts in your source tree that keeps the whole project in one program, and include patterns broad enough that opening one file loads ten thousand.

What TypeScript 7 changes

The native Go port is roughly 8-12x faster on full builds, with parallel type-checking behind --checkers and --builders. That is a real, large win and it is free — you do not have to change your code to get it.

What it does not change is the shape of the work. A union with 50,000 members is still a union with 50,000 members; the port computes it faster, on more cores. Teams with type-heavy codebases report smaller multiples than the headline numbers for exactly this reason, and if your CI is I/O bound the end-to-end improvement can be modest. Upgrading is worth it. It is not a substitute for deleting the type that generates 40 million instantiations.

When not to optimize

Most projects do not have a TypeScript performance problem. They have a ten-second build that feels slow because the editor is also doing something else.

Before you restructure anything: run --extendedDiagnostics, write the number down, make one change, run it again. If the number did not move, revert. Type-level code that is fast and unreadable is a bad trade, and "clever generic replaced by a hand-written interface" is a fine outcome — the interface is faster and the error messages are better.

Summary

TypeScript performance work has an order to it. Measure with --extendedDiagnostics and narrow down with --generateTrace. Attack unions and recursive conditional types first, because they are where instantiation counts explode. Prefer interface extends to intersections and annotate your exported return types. Then turn on skipLibCheck and incremental, and reach for project references when a monorepo outgrows a single program.

TypeScript 7 makes all of that faster, not unnecessary. If you want the deeper reference, Microsoft's performance wiki page is the canonical list, and utility types covers the built-in helpers that are already optimized so you do not have to write your own.

Share this article

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript performance knowledge to the test with these related challenges.

#4Pick
Easy
#3312Parameters
Easy
#2Get Return Type
Medium

Related Concepts

Concepts that build on or relate to typescript performance.

tsconfig.json ExplainedUnion TypesIntersection TypesMapped TypesTypeScript GenericsTypeScript Utility Types