Type Checking Performance
Why an editor becomes slow: measuring with compiler diagnostics, finding the type that costs seconds, and the rewrites that give the time back.
Why an editor becomes slow: measuring with compiler diagnostics, finding the type that costs seconds, and the rewrites that give the time back.
Autocomplete takes four seconds. Hovering over a variable shows a spinner. The type check that used to take twenty seconds now takes three minutes, and nobody knows what changed — the commit that did it added forty lines and looked harmless.
Type checking is computation, and some types cost enormously more than others. By the end of this lesson you will be able to find the type responsible rather than guessing, know the handful of patterns that account for most slowdowns, and know which fixes are worth their trade.
Guessing which type is expensive is unreliable — it is rarely the one that looks most complicated.
npx tsc --noEmit --extendedDiagnosticsFiles: 1204
Types: 184203
Instantiations: 28471039
Memory used: 1841203K
Check time: 142.3s
Total time: 156.8sInstantiations is the number to watch. It counts generic types being created, and a figure in the tens of millions means something is being expanded repeatedly. A healthy medium project is in the low millions.
For the culprit, produce a trace:
npx tsc --noEmit --generateTrace ./trace
npx @typescript/analyze-trace ./traceHot spots:
src/api/client.ts:88:14 - 41.2s
checkExpression
instantiateType (DeepPartial) x 8,412That names a file, a line and the type being instantiated eight thousand times. The output is not always this tidy, and it is always more informative than reading code and forming a theory.
Two cheaper checks worth running first:
npx tsc --noEmit --listFiles | wc -lIf that number is far larger than the files you wrote, include
is pulling in generated output, fixtures or dist. That is the
most common cause and the easiest fix.
npx tsc --noEmit --explainFiles | grep -A2 "some-huge-package"That tells you why a file is included, which occasionally reveals a dependency dragging in an enormous declaration file.
Five patterns account for most of it.
Deep recursive types
DeepPartial, DeepReadonly, a path parser. Each
instantiation walks the whole structure, and it happens again
on every hover.
Large unions, especially multiplied
A template literal over four unions of twenty members is 160,000 types. The slowdown starts long before the cap.
Long intersection chains
A & B & C & D & E is compared property by property against
everything it meets, and unlike a named interface the result
is not cached.
Conditional types in a hot signature
A conditional return type on a function called five hundred times is evaluated five hundred times.
Deeply nested generic instantiation
Foo<Bar<Baz<Qux<T>>>>, where each layer is itself generic.
What does not cost much: the number of files, simple interfaces, ordinary unions of a few members, and plain functions. A large codebase of straightforward types checks quickly.
Bad — an intersection used everywhere.
type Base = { id: string; createdAt: Date };
type Timestamps = { updatedAt: Date; deletedAt: Date | null };
type Auditable = { createdBy: string; updatedBy: string };
type Photo = Base & Timestamps & Auditable & {
name: string;
size: number;
};Good — an interface with
extends.
interface Photo extends Base, Timestamps, Auditable {
name: string;
size: number;
}Recomputed at every comparison.
Every constituent is compared again each time the type takes part in an assignability check.
Small for one type; multiplied by hundreds of signatures it is most of a slow project.
Flattened once and cached.
Subsequent comparisons are a single lookup.
This is the most effective structural change available and it costs nothing — only the performance differs.
The rule: interfaces with extends for object shapes;
intersections only when you need something an interface cannot
express — combining with a union, or with a mapped type.
Annotate return types on exported functions. Without an annotation the compiler must infer, and an inferred type propagates into every declaration file and every caller:
export function build(config: Config): Result { ... }That also pins the contract, which the library lesson wanted for different reasons.
Cache an expensive type behind a name. A named type alias is still recomputed, but hoisting it out of a signature means it is instantiated once per use rather than per constituent:
type PhotoPatch = DeepPartial<Photo>;
function update(patch: PhotoPatch): void { ... }Replace a computed type with a generated one. If
DeepPartial<Photo> is expensive and Photo changes rarely,
generate the flattened type to a file — the compiler-API
lesson's argument, with performance as the motivation.
Bound recursion. A depth counter turns an unbounded walk into a fixed cost, and makes a pathological input your error rather than a hang.
Use project references. From the practice course: changing one package does not recheck the others, and each gets its own cached build info.
Turn on skipLibCheck and incremental:
{
"compilerOptions": {
"skipLibCheck": true,
"incremental": true,
"tsBuildInfoFile": "node_modules/.cache/tsconfig.tsbuildinfo"
}
}skipLibCheck is a large win and hides conflicts between
dependencies — a trade nearly everyone accepts.
A performance fix with nothing watching it comes back.
- run: |
npx tsc --noEmit --extendedDiagnostics 2>&1 | tee diagnostics.txt
INSTANTIATIONS=$(grep Instantiations diagnostics.txt | \
grep -o '[0-9]*')
echo "instantiations: $INSTANTIATIONS"
if [ "$INSTANTIATIONS" -gt 15000000 ]; then
echo "::warning::instantiation count is rising"
fiA generous threshold that catches a tenfold regression and ignores noise — the same principle as the benchmark test in the Python performance lesson.
And two habits worth more than any tooling. Notice when your editor slows down, because you are the fastest detector available. And when a review adds a type with three levels of conditional recursion, ask what it costs — the answer is usually unknown, which is itself the finding.
MEASURE - never guess which type is expensive
tsc --noEmit --extendedDiagnostics
Instantiations is the number: millions fine, tens of
millions means repeated expansion
tsc --noEmit --generateTrace ./trace
+ npx @typescript/analyze-trace ./trace -> file, line, type
first, the cheap checks:
--listFiles | wc -l is include pulling in dist/fixtures?
--explainFiles WHY is this file here?
WHAT COSTS
deep recursive types (DeepPartial, path parsers)
large unions, especially multiplied by template literals
long intersection chains
conditional return types in a hot signature
nested generic instantiation
NOT expensive: file count, simple interfaces, small unions
THE BIGGEST STRUCTURAL WIN
interface Photo extends A, B, C { ... } flattened ONCE, cached
type Photo = A & B & C & { ... } compared every time
equivalent for object shapes; only the cost differs
intersections only for what an interface cannot express
FIXES
annotate return types on exported functions
hoist an expensive type behind a name
GENERATE a flattened type to a file when the source changes rarely
bound recursion with a depth counter
project references
skipLibCheck + incremental + tsBuildInfoFile
EDITOR VS BUILD
slow editor, fast build -> one type in the file you are in
slow build, fine editor -> too many files
REGRESSION
print the instantiation count in CI with a generous threshold
notice when your editor slows - you are the best detector
ask what a new recursive type costs; "unknown" is the findingYou can now find the type making your editor slow rather than
theorising about it, and you know the one structural change —
interface extends over long intersections — that helps most for
no cost.
Next is Decorators and Metadata, which covers the standardised decorator model: what each kind can do, how metadata works now, and the narrow cases where a decorator beats a plain function call.
Before you move on, run --extendedDiagnostics on a project you
work in and write down the instantiation count. That number is
the baseline you will want the next time somebody asks why the
editor got slow.