Von generateTrace bis Project References
Wer TypeScript-Projekte wachsen lässt, ohne die Compiler-Performance im Blick zu behalten, erlebt irgendwann minutenlange Build-Zeiten und einen tsserver, der im Editor spürbar hängt. Kombinatorische Typinstanziierung, riesige Union-Typen und fehlende Project References sind die häufigsten Ursachen. Dieser Artikel zeigt praxisnah, wie man langsame Builds mit generateTrace diagnostiziert, typische Fallen erkennt und mit Project References sowie inkrementellen Builds gezielt beschleunigt.
Inhaltsverzeichnis
- 1. Warum die Type-Checking-Performance nicht linear mit der Codebasis wächst
- 2. Langsame Builds diagnostizieren: --generateTrace und --extendedDiagnostics
- 3. Exzessive Typinstanziierung: wenn TypeScript sich selbst verschluckt
- 4. Weitere Performance-Killer: verschachtelte Typen, riesige Unions, generische Abstraktionen
- 5. Project References: grosse Codebasen sauber aufteilen
- 6. Inkrementelle Builds und skipLibCheck als pragmatische Lösung
- 7. Editor-Performance: warum tsserver in grossen Projekten stockt
- 8. Build-Orchestrierung: Caching und Parallelisierung in Monorepos
- 9. Langsame vs. performante TypeScript-Patterns im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum die Type-Checking-Performance nicht linear mit der Codebasis wächst
Der TypeScript-Compiler prüft nicht jede Datei isoliert, sondern baut einen vollständigen Programmgraphen aus allen Modulen, Imports und Typbeziehungen auf. Jede neue Datei kann potenziell mit jeder anderen über gemeinsame Typen verbunden sein, wodurch die Prüfzeit nicht proportional, sondern oft quadratisch oder schlimmer mit der Projektgrösse wächst. Ein Projekt mit doppelt so vielen Zeilen braucht deshalb selten nur doppelt so lange zum Prüfen.
Besonders kritisch wird es, wenn generische Typen mit vielen unterschiedlichen Typargumenten instanziiert werden: Jede Kombination erzeugt eine eigene, strukturell zu prüfende Typinstanz, und diese Instanzen vermehren sich kombinatorisch, sobald mehrere generische Ebenen ineinandergreifen. Ein einzelner, harmlos wirkender Utility-Type in einer gemeinsam genutzten Bibliothek kann so an hunderten Aufrufstellen gleichzeitig neu instanziiert werden und die Build-Zeit des gesamten Projekts spürbar verlängern.
In der Praxis zeigt sich das zuerst am CI-Server: Ein Build, der vor sechs Monaten zwei Minuten brauchte, dauert plötzlich zwölf. Nicht ein einzelner grosser Refactor ist meist die Ursache, sondern die Summe vieler kleiner generischer Abstraktionen, die sich über die Zeit angesammelt haben.
2. Langsame Builds diagnostizieren: --generateTrace und --extendedDiagnostics
Bevor man optimiert, muss man messen, wo die Zeit tatsächlich verloren geht. tsc --generateTrace trace-output schreibt eine detaillierte Ereignis-Trace im Chrome-Tracing-Format, die sich direkt in chrome://tracing oder dem Edge-Pendant laden lässt. Die Trace zeigt pro Datei, wie lange Parsing, Binding und Type-Checking gedauert haben, und macht sichtbar, welche einzelnen Dateien überproportional viel Zeit beanspruchen, statt nur eine Gesamtzahl zu liefern.
Das offizielle Tool @typescript/analyze-trace wertet dieselbe Trace automatisiert aus und listet die teuersten Type-Checks direkt in der Konsole, inklusive Dateiname, Zeilennummer und benötigter Zeit. Für einen schnellen ersten Eindruck ohne vollständige Trace genügt tsc --extendedDiagnostics: Es liefert unter anderem Anzahl geprüfter Dateien, Anzahl der Typinstanziierungen und die reine Check-Zeit als nackte Zahlen, die sich gut zwischen Commits vergleichen lassen.
#!/usr/bin/env bash
# Generate a compiler trace for the whole program
npx tsc --generateTrace trace-output -p tsconfig.json
# Install and run the official trace analyzer against the generated trace
npm install --no-save @typescript/analyze-trace
npx analyze-trace trace-output
# trace-output/trace.json can also be loaded manually in chrome://tracing
# Quick numeric stats without a full trace: check time, instantiation count
npx tsc --noEmit --extendedDiagnostics -p tsconfig.json
# Typical extendedDiagnostics output:
# Files: 842
# Types: 58211
# Instantiations: 301744
# Check time: 9.87s
3. Exzessive Typinstanziierung: wenn TypeScript sich selbst verschluckt
Ein besonders hartnäckiges Performance-Problem sind rekursive konditionale Typen ohne sauberen Basisfall. Jeder rekursive Aufruf erzeugt eine neue Typinstanz, und ohne eine Abbruchbedingung, die die Rekursionstiefe begrenzt, wächst die Anzahl der Instanzen bei tief verschachtelten Eingabetypen explosionsartig. TypeScript bricht solche Ketten irgendwann mit dem Fehler Type instantiation is excessively deep and possibly infinite ab, oft erst bei Typen, die in der Praxis harmlos wirken.
Ähnlich gefährlich sind Template-Literal-Typen, die mehrere Union-Typen kombinieren: Ein Template mit drei Platzhaltern, die jeweils zehn Varianten haben, erzeugt bereits tausend konkrete String-Literal-Typen, die der Compiler einzeln vorhält. Die Lösung ist fast immer dieselbe: Rekursion mit einem Tiefenzähler begrenzen, der bei Erreichen von null einfach den bisherigen Zwischenstand zurückgibt, statt endlos weiterzurekurrieren.
Das folgende Beispiel zeigt beide Varianten nebeneinander: die naive Rekursion, die bei tief verschachtelten Tupeln an ihre Grenzen stösst, und die tiefenbegrenzte Version, die dasselbe Ergebnis liefert, ohne den Compiler zu überfordern.
// Naive recursive conditional type: no depth limit and no base case
// for the recursion, risks "Type instantiation is excessively deep
// and possibly infinite" (TS2589) on deeply nested input types
type DeepFlatten<T> = T extends readonly [infer Head, ...infer Tail]
? Head extends readonly unknown[]
? [...DeepFlatten<Head>, ...DeepFlatten<Tail>]
: [Head, ...DeepFlatten<Tail>]
: [];
// Fails on large or very deeply nested tuples:
// type Huge = DeepFlatten<[[[[[[[[[[1]]]]]]]]]]>; // excessively deep
// Safer version: depth-limited recursion using a lookup tuple that
// decrements a counter type instead of recursing without a bound
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8];
type DeepFlattenLimited<T, D extends number = 8> = D extends 0
? T
: T extends readonly [infer Head, ...infer Tail]
? Head extends readonly unknown[]
? [...DeepFlattenLimited<Head, Prev[D]>, ...DeepFlattenLimited<Tail, D>]
: [Head, ...DeepFlattenLimited<Tail, D>]
: [];
// Terminates deterministically after at most 8 levels of recursion
type Safe = DeepFlattenLimited<[[[[1]]]]>;
4. Weitere Performance-Killer: verschachtelte Typen, riesige Unions, generische Abstraktionen
Instanziierungstiefe ist nicht der einzige Performance-Killer. Tief verschachtelte konditionale Typen, die eigentlich nur eine einfache Fallunterscheidung abbilden sollen, zwingen den Compiler zu wiederholten strukturellen Vergleichen, obwohl ein einzelnes Mapped Type oder ein simples Lookup denselben Effekt mit einem Bruchteil des Aufwands erzielen würde. Genauso teuer sind Discriminated Unions mit hunderten Mitgliedern, die in einem einzigen exhaustiven switch ausgewertet werden: Jede Verzweigung zwingt den Compiler dazu, die gesamte Union erneut zu narrowen.
Ein dritter, oft übersehener Fall sind übermässig generische Utility-Type-Abstraktionen, die über mehrere Ebenen gestapelt werden. Jede zusätzliche generische Schicht wird an jeder Aufrufstelle neu berechnet, da TypeScript Typen nicht projektweit cacht, sondern pro Verwendungsstelle instanziiert. Eine Bibliothek mit fünf ineinander verschachtelten generischen Hilfstypen kann so an tausenden Stellen im Projekt tausendfach neu ausgewertet werden, ohne dass ein einzelner Aufruf für sich genommen auffällig wäre.
Das folgende Beispiel zeigt eine Redux-artige Action-Union mit hunderten Varianten und ihre schlankere Alternative, die dieselbe Typsicherheit aus einer einzigen Payload-Map ableitet.
// Bloated: hundreds of near-identical members inflate the union type,
// and every access forces the checker to narrow across all of them
type Action =
| { type: "user/create"; payload: { name: string; email: string } }
| { type: "user/update"; payload: { id: string; name: string } }
| { type: "user/delete"; payload: { id: string } }
// ...200+ more variants follow the exact same shape per domain
| { type: "order/create"; payload: { sku: string; qty: number } };
function reduce(action: Action) {
switch (action.type) {
case "user/create": return handleUserCreate(action.payload);
case "user/update": return handleUserUpdate(action.payload);
// ...200+ more case labels, each re-narrowing the full union
default: return action;
}
}
// Leaner: derive the union from a single payload map instead of
// hand-writing every member, the checker only narrows one lookup
interface ActionPayloadMap {
"user/create": { name: string; email: string };
"user/update": { id: string; name: string };
"user/delete": { id: string };
"order/create": { sku: string; qty: number };
}
type LeanAction = {
[K in keyof ActionPayloadMap]: { type: K; payload: ActionPayloadMap[K] };
}[keyof ActionPayloadMap];
const handlers: { [K in keyof ActionPayloadMap]: (p: ActionPayloadMap[K]) => void } = {
"user/create": handleUserCreate,
"user/update": handleUserUpdate,
"user/delete": handleUserDelete,
"order/create": handleOrderCreate,
};
function reduceLean(action: LeanAction) {
return handlers[action.type](action.payload as never);
}
5. Project References: grosse Codebasen sauber aufteilen
Project References sind das wichtigste strukturelle Werkzeug gegen wachsende Build-Zeiten in grossen Codebasen. Statt ein einziges, monolithisches tsconfig.json über das gesamte Repository zu prüfen, wird das Projekt in unabhängige Teilprojekte mit composite: true aufgeteilt, die über references aufeinander verweisen. Jedes Teilprojekt wird einzeln geprüft und kompiliert, erzeugt eigene Deklarationsdateien, und der Compiler muss nur die öffentlichen .d.ts-Signaturen der abhängigen Pakete kennen, nicht deren vollständigen internen Quellcode.
Der entscheidende Vorteil zeigt sich beim erneuten Bauen: Mit tsc --build läuft der Compiler den Referenzgraphen ab und baut nur die Projekte neu, deren Quellcode oder Abhängigkeiten sich seit dem letzten Lauf tatsächlich geändert haben. In einem Monorepo mit zehn Paketen bedeutet eine kleine Änderung in einem Blatt-Paket keinen vollständigen Rebuild mehr, sondern nur die Neuprüfung dieses einen Pakets und der Pakete, die direkt davon abhängen.
// tsconfig.json (solution file at the monorepo root, no source files)
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/api-client" },
{ "path": "./packages/web" }
]
}
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"target": "ES2022",
"module": "ESNext",
"strict": true
},
"include": ["src"]
}
// packages/web/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../core" },
{ "path": "../api-client" }
],
"include": ["src"]
}
6. Inkrementelle Builds und skipLibCheck als pragmatische Lösung
Inkrementelle Builds ergänzen Project References um eine zweite Ebene der Wiederverwendung: Mit incremental: true schreibt der Compiler nach jedem Lauf eine .tsbuildinfo-Datei, die Dateihashes, Programmstruktur und Diagnosen zwischenspeichert. Beim nächsten Aufruf vergleicht tsc die aktuellen Dateien mit diesem Snapshot und prüft nur, was sich seitdem geändert hat, statt das gesamte Programm erneut von Grund auf zu analysieren.
skipLibCheck: true ist die pragmatischste Einzelmassnahme mit dem grössten Hebel: Sie überspringt die vollständige Typprüfung aller .d.ts-Dateien aus node_modules, die in grossen Projekten oft mehrere hundert sind und deren Typen bereits von den jeweiligen Paketautoren geprüft wurden. Der Kompromiss ist gering, weil Inkompatibilitäten zwischen zwei Bibliotheksdeklarationen ohnehin selten am eigenen Code liegen, und der Zeitgewinn gerade bei Projekten mit vielen Abhängigkeiten erheblich ist.
Beide Optionen kombiniert reduzieren wiederholte Build-Zeiten in der Praxis häufig um mehr als die Hälfte, ohne dass am eigentlichen Code etwas geändert werden muss.
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./dist/.tsbuildinfo",
"skipLibCheck": true,
"composite": true,
"declaration": true,
"target": "ES2022",
"module": "ESNext",
"strict": true
},
"include": ["src"]
}
// First run: full check, writes ./dist/.tsbuildinfo
// Subsequent runs: tsc reuses the cached program state and only
// re-checks files whose content or dependencies actually changed
7. Editor-Performance: warum tsserver in grossen Projekten stockt
Die Build-Performance ist nur die halbe Geschichte: Im Editor prüft tsserver Typen kontinuierlich während des Tippens, und in grossen Projekten macht sich das zuerst durch verzögerte Autovervollständigung, träges Hover und ein Aufblähen des Speicherverbrauchs bemerkbar. Anders als ein CI-Build läuft tsserver dauerhaft im Hintergrund und muss bei jeder Tastatureingabe neu entscheiden, welcher Teil des Programmgraphen betroffen ist.
Ein häufiger, leicht behebbarer Fehler ist ein zu weit gefasstes include-Muster, das versehentlich generierte Dateien, Build-Ausgaben oder sogar Teile von node_modules in den Sprachdienst zieht. Project References helfen auch hier: tsserver lädt pro geöffneter Datei nur das relevante Teilprojekt statt des gesamten Repositorys. Bei sehr grossen Monorepos lässt sich zusätzlich typescript.tsserver.maxTsServerMemory in VS Code erhöhen, um Abstürze durch Speichermangel zu vermeiden.
Wer regelmässig einen einzelnen tsserver-Prozess mit mehreren Gigabyte Arbeitsspeicher beobachtet, hat fast immer ein zu grosses oder zu schlecht abgegrenztes Projekt im Sprachdienst geladen.
8. Build-Orchestrierung: Caching und Parallelisierung in Monorepos
In Monorepos mit mehreren Teams reicht Project References allein oft nicht aus: Build-Orchestrierungs-Tools wie Nx oder Turborepo bauen einen Abhängigkeitsgraphen über alle Pakete hinweg und cachen die Ergebnisse jedes Build-Schritts, einschliesslich der erzeugten .tsbuildinfo-Dateien. Ändert sich ein Paket nicht, wird der zwischengespeicherte Output direkt wiederverwendet, lokal wie in der CI-Pipeline, ohne dass tsc überhaupt erneut aufgerufen wird.
Remote Caching geht noch einen Schritt weiter: Der Build-Output landet in einem gemeinsamen Cache-Server, sodass ein Kollege, der denselben Commit auscheckt, den bereits geprüften Zustand herunterlädt statt lokal neu zu bauen. Für CI-Pipelines bedeutet das, node_modules und die .tsbuildinfo-Dateien konsequent über den Lockfile-Hash zu cachen, damit ein reiner Dokumentations- oder CSS-Commit keinen vollständigen TypeScript-Check auslöst.
Parallelisierung über den Referenzgraphen ist der letzte Baustein: Unabhängige Teilprojekte lassen sich gleichzeitig prüfen, solange die Build-Reihenfolge die tatsächlichen Abhängigkeiten respektiert.
9. Langsame vs. performante TypeScript-Patterns im Vergleich
Die folgenden fünf Muster tauchen in wachsenden TypeScript-Projekten immer wieder auf und lassen sich fast ausnahmslos durch eine gezielte, oft kleine Änderung ersetzen. Der Unterschied ist selten stilistisch, sondern hat direkte Auswirkungen auf Build-Zeit und Speicherverbrauch, insbesondere in grossen Monorepos mit vielen Paketen.
| Szenario | Performance-teures Pattern | Performance-bewusste Alternative | Effekt |
|---|---|---|---|
| Rekursive Typen | DeepFlatten<T> ohne Basisfall |
Tiefenbegrenzte Rekursion mit Zähler | Kein TS2589 mehr bei tiefer Verschachtelung |
| Projektstruktur | Eine monolithische tsconfig.json | Project References mit composite: true | Nur geänderte Teilprojekte werden neu geprüft |
| node_modules prüfen | Volle Prüfung aller .d.ts-Dateien | skipLibCheck: true |
Deutlich kürzere Check-Zeit bei vielen Deps |
| Discriminated Union | 200+ handgeschriebene Union-Mitglieder | Union aus einer Payload-Map ableiten | Weniger Narrowing-Aufwand pro switch |
| CI-Build | Voller Rebuild bei jedem Commit | incremental + gecachte .tsbuildinfo | Nur geänderte Dateien werden re-analysiert |
Alle fünf Optimierungen lassen sich unabhängig voneinander einführen und summieren sich in der Praxis: Projekte, die alle fünf Muster konsequent anwenden, berichten von Build-Zeit-Reduktionen zwischen 40 und 80 Prozent, je nach Ausgangslage und Projektgrösse.
Mironsoft
TypeScript-Tooling, Build-Performance und Monorepo-Setup
TypeScript-Builds, die nicht mehr bremsen?
Wir analysieren eure TypeScript-Codebasis mit generateTrace und extendedDiagnostics, identifizieren die teuersten Typinstanziierungen und richten Project References sowie inkrementelle Builds für euren Monorepo-Stack ein.
Performance-Audit
Trace-Analyse und Identifikation der teuersten Typinstanziierungen
Build-Refactoring
Project References, composite Pakete und schlankere Typabstraktionen
CI-Caching-Setup
.tsbuildinfo und node_modules gezielt in der Pipeline cachen
10. Zusammenfassung
Die TypeScript-Performance in grossen Projekten hängt von wenigen, aber entscheidenden Stellschrauben ab: Diagnose vor Optimierung mit generateTrace und extendedDiagnostics, eine bewusste Begrenzung der Typinstanziierungstiefe bei rekursiven Typen, schlanke Discriminated Unions statt hunderter handgeschriebener Varianten, und eine strukturelle Aufteilung über Project References. Wer diese vier Punkte in dieser Reihenfolge angeht, behebt die grössten Kostentreiber zuerst, statt Symptome punktuell zu kaschieren.
Inkrementelle Builds mit .tsbuildinfo, skipLibCheck und eine funktionierende Cache-Strategie in der CI-Pipeline sind der pragmatische zweite Schritt, der sich fast ohne Risiko einführen lässt. Zusammen mit einer bewussten Projektstruktur für tsserver bleibt TypeScript auch bei zehntausenden Zeilen Code und mehreren Teams ein produktives Werkzeug statt eine tägliche Wartezeit.
TypeScript-Performance in grossen Projekten optimieren: Das Wichtigste auf einen Blick
Diagnose zuerst
tsc --generateTrace und --extendedDiagnostics vor jeder Optimierung, um die tatsächlichen Kostentreiber zu finden.
Instanziierungstiefe begrenzen
Rekursive konditionale Typen immer mit Tiefenzähler oder klarem Basisfall versehen.
Project References
Grosse Codebasen in composite Teilprojekte aufteilen, nur geänderte Pakete neu bauen.
Inkrementell bauen
.tsbuildinfo cachen, skipLibCheck aktivieren, Cache-Strategie in der CI-Pipeline etablieren.