TypeScript Performance
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 — how long type-checking took. If this dominates, your types are the problem.
- Parse time and Program time — reading files off disk. If these dominate, you are compiling too many files.
- Types and Instantiations — how many types the checker created. Instantiations in the tens of millions on a mid-sized project means something is generating types combinatorially.
- Memory used — the one that explains editor crashes.
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.
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