TypeScript-Performance in grossen Projekten optimieren
<T>
type
TypeScript · Performance · Compiler · Tooling
TypeScript-Performance in grossen Projekten optimieren
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.

17 Min. Lesezeit tsc · generateTrace · Project References TypeScript 5.x · Node.js · Monorepo

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.

11. FAQ: TypeScript-Performance in grossen Projekten optimieren

1Warum wächst die TypeScript-Compile-Zeit nicht linear mit der Projektgrösse?
Der Compiler baut einen vollständigen Programmgraphen aus allen Dateien und Typbeziehungen. Neue Dateien können mit vielen bestehenden verbunden sein, wodurch die Prüfzeit oft quadratisch statt linear wächst.
2Was macht tsc --generateTrace und wie liest man die Trace-Datei?
Schreibt eine Ereignis-Trace im Chrome-Tracing-Format, ladbar in chrome://tracing. Zeigt pro Datei die Dauer von Parsing, Binding und Type-Checking.
3Wofür wird @typescript/analyze-trace verwendet?
Wertet eine Trace automatisiert aus und listet die teuersten Type-Checks direkt in der Konsole, inklusive Datei, Zeile und benötigter Zeit.
4Was sagt tsc --extendedDiagnostics aus?
Liefert schnelle numerische Kennzahlen wie Dateianzahl, Typinstanziierungen und Check-Zeit, ohne eine vollständige Trace zu erzeugen.
5Was bedeutet "excessively deep and possibly infinite"?
TypeScript bricht die Auswertung ab, wenn die Instanziierungstiefe eine interne Grenze überschreitet. Häufigste Ursache: rekursive Typen ohne Basisfall oder Tiefenbegrenzung.
6Warum sind riesige Discriminated Unions ein Performance-Problem?
Jede Verzweigung in einem exhaustiven switch zwingt den Compiler, die gesamte Union erneut zu narrowen. Eine aus einer Payload-Map abgeleitete Union reduziert diesen Aufwand deutlich.
7Was bringen Project References konkret?
Teilen ein Repository in unabhängige composite Teilprojekte auf. tsc --build baut nur die Projekte neu, deren Code sich geändert hat.
8Wofür ist skipLibCheck gedacht und ist es sicher?
Überspringt die vollständige Typprüfung von .d.ts-Dateien aus node_modules. In der Praxis sicher, da diese Typen bereits von den Paketautoren geprüft wurden.
9Wie funktioniert incremental mit .tsbuildinfo?
Der Compiler schreibt Dateihashes und Programmstruktur in eine .tsbuildinfo-Datei und prüft beim nächsten Lauf nur, was sich seitdem geändert hat.
10Warum wird der Editor (tsserver) in grossen Projekten langsam?
tsserver prüft Typen kontinuierlich beim Tippen und lädt oft mehr Dateien als nötig. Project References und ein enger gefasstes include begrenzen die geladene Codemenge.