für TypeScript-Monorepos, die nicht bei jedem Build neu kompilieren
composite, references und tsc --build ersetzen den vollständigen Neu-Compile durch inkrementelle, parallelisierbare Teil-Builds pro Paket.
Inhaltsverzeichnis
- 1. Das Problem: langsame tsc-Builds in Monorepos
- 2. composite und references in tsconfig.json
- 3. Build-Reihenfolge und der .tsbuildinfo-Cache
- 4. tsc --build: inkrementell und mit --build parallelisierbar
- 5. declarationMap und Cross-Package Go-to-Definition
- 6. Path Mapping vs. Project References: der Unterschied
- 7. Typische Fallstricke: zirkuläre Referenzen, outDir-Konflikte
- 8. Integration mit Turborepo und Nx
- 9. Migrationsstrategie für bestehende Monorepos
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem: langsame tsc-Builds in Monorepos
Ohne Project References behandelt ein einzelner tsc-Lauf über ein Monorepo alle Pakete als eine große Kompilationseinheit. Jede Änderung, auch in einem einzelnen Blatt-Paket, zwingt den Compiler dazu, den gesamten Typgraphen erneut zu prüfen.
Bei mittelgroßen Monorepos mit einem Dutzend Paketen bedeutet das oft Kompilierzeiten im zweistelligen Sekundenbereich für eine Ein-Zeilen-Änderung, was den Watch-Modus in der IDE spürbar träge macht und den Feedback-Loop beim Entwickeln unterbricht.
Project References lösen dieses Problem strukturell: jedes Paket wird zu einer eigenen Kompilationseinheit mit klar definierten Abhängigkeiten, sodass der Compiler nur die tatsächlich betroffenen Pakete neu bauen muss, nicht das gesamte Repository.
Ohne diese Struktur wachsen Kompilierzeiten in der Praxis überproportional mit der Anzahl der Pakete, weil jede neue Datei den gesamten Typgraphen weiter aufbläht. Mit wachsendem Team und wachsendem Repository wird dieser Effekt zunehmend zum größten Reibungspunkt im täglichen Entwickeln.
2. composite und references in tsconfig.json
Ein Paket wird referenzierbar, indem composite: true in seiner tsconfig gesetzt wird. Das erzwingt zusätzliche Regeln: alle Eingabedateien müssen explizit vom include-Muster erfasst sein, und declaration wird implizit aktiviert, weil abhängige Pakete die generierten .d.ts-Dateien statt des Quellcodes konsumieren.
Im konsumierenden Paket verweist das references-Array auf die Pfade der abhängigen Pakete. TypeScript liest daraus einen gerichteten azyklischen Graphen und weiß dadurch genau, in welcher Reihenfolge Pakete gebaut werden müssen.
Wichtig ist, dass references nur die direkte Build-Reihenfolge steuert, nicht die Modulauflösung selbst. Für echte Importe zwischen Paketen sind weiterhin Workspace-Verlinkungen über npm, pnpm oder Yarn nötig, Project References ergänzen diese, ersetzen sie aber nicht.
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
// packages/api/tsconfig.json
{
"compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../core" }],
"include": ["src"]
}
3. Build-Reihenfolge und der .tsbuildinfo-Cache
Bei jedem Build eines referenzierten Pakets schreibt TypeScript eine .tsbuildinfo-Datei, die Dateihashes, Diagnosen und Abhängigkeitsinformationen enthält. Beim nächsten Build vergleicht der Compiler diesen Zustand und kompiliert nur, was sich seit dem letzten Lauf tatsächlich geändert hat.
Dieser Cache ist der eigentliche Grund für die Geschwindigkeit: ein Build ohne Änderungen dauert dank .tsbuildinfo nur Millisekunden, weil der Compiler früh erkennt, dass kein Paket im Graphen neu kompiliert werden muss.
In CI-Umgebungen lohnt es sich, die .tsbuildinfo-Dateien zwischen Runs zu cachen, etwa über den Build-Cache von GitHub Actions oder GitLab CI, weil ein kalter Cache den Geschwindigkeitsvorteil für den ersten Build wieder zunichtemacht.
4. tsc --build: inkrementell und mit --build parallelisierbar
Der Befehl tsc --build (kurz tsc -b) liest den Referenzgraphen ausgehend von der angegebenen tsconfig und baut alle Abhängigkeiten in korrekter Reihenfolge, bevor das Zielpaket selbst gebaut wird.
Mit dem Flag --watch kombiniert entsteht ein Multi-Package-Watch-Modus, der nach einer Änderung in einem Kernpaket automatisch nur die davon abhängigen Pakete neu baut, anstatt den kompletten Watch-Prozess neu zu starten.
Zusätzliche Flags wie --verbose zeigen an, welche Pakete übersprungen wurden und warum, was beim Debuggen von unerwartet langsamen Builds oder falsch konfigurierten Referenzen unverzichtbar ist.
# Baut alle referenzierten Pakete in korrekter Reihenfolge
tsc --build packages/api
# Watch-Modus über den gesamten Referenzgraphen
tsc --build --watch
# Erzwingt einen vollständigen Rebuild, ignoriert .tsbuildinfo
tsc --build --force
5. declarationMap und Cross-Package Go-to-Definition
Ohne declarationMap: true führt Go to Definition auf ein Symbol aus einem referenzierten Paket lediglich in die generierte .d.ts-Datei, nicht in den eigentlichen Quellcode. Für tägliche Entwicklungsarbeit ist das unpraktisch, weil Kommentare und die tatsächliche Implementierung fehlen.
Mit aktiviertem declarationMap generiert TypeScript zusätzliche Sourcemap-Dateien für die Deklarationen, sodass der Editor bei Go-to-Definition direkt in die .ts-Quelldatei des referenzierten Pakets springt, inklusive aller Kommentare und JSDoc-Blöcke.
Dieses Feature ist besonders in großen Monorepos ein spürbarer Produktivitätsgewinn, weil Entwickler beim Navigieren zwischen Paketgrenzen nicht mehr merken, dass sie überhaupt eine Paketgrenze überschritten haben.
6. Path Mapping vs. Project References: der Unterschied
Viele Projekte nutzen paths in der tsconfig für kürzere Importe wie @core/utils. Das ist reines Alias-Mapping für die Modulauflösung und hat keinerlei Einfluss auf Build-Reihenfolge oder inkrementelle Kompilierung.
Project References lösen ein anderes Problem: sie definieren, welche Kompilationseinheiten existieren und in welcher Reihenfolge sie gebaut werden müssen. Beide Mechanismen schließen sich nicht aus, sondern werden in der Praxis meist kombiniert.
Ein häufiger Fehler ist die Annahme, paths allein würde Cross-Package-Typsicherheit garantieren. Ohne composite und references prüfen sich Pakete gegenseitig nicht konsistent, und Änderungen an einem Kernpaket färben nicht zuverlässig auf abhängige Pakete ab.
7. Typische Fallstricke: zirkuläre Referenzen, outDir-Konflikte
Zirkuläre Referenzen zwischen zwei Paketen führt TypeScript mit einer klaren Fehlermeldung ab, weil der Referenzgraph azyklisch sein muss. In der Praxis deutet ein solcher Zyklus fast immer auf ein Architekturproblem hin, das eine Extraktion eines gemeinsamen Basis-Pakets erfordert.
Ein weiterer häufiger Fehler ist ein falsch konfiguriertes outDir, das versehentlich Quelldateien anderer Pakete überschreibt, wenn rootDir nicht sauber pro Paket gesetzt ist. Die Fehlermeldung dazu ist oft kryptisch und zeigt sich erst als unerwartet leere oder überschriebene Ausgabedatei.
Auch das Vergessen von declaration: true in einem referenzierten Paket ist ein Klassiker: ohne generierte .d.ts-Dateien können abhängige Pakete das Paket zwar im Dateisystem finden, aber keine sinnvollen Typen daraus importieren.
8. Integration mit Turborepo und Nx
Build-Orchestratoren wie Turborepo oder Nx bauen eine eigene Aufgaben-Graph-Logik oberhalb des Paketmanagers, die sich mit Project References ergänzt statt konkurriert: Project References sorgen für korrekte TypeScript-Builds pro Paket, Turborepo oder Nx orchestrieren, cachen und parallelisieren diese Aufgaben über das gesamte Repository.
In der Praxis ruft eine Turborepo-Pipeline pro Paket typischerweise tsc --build auf und cached die Ausgabe inklusive .tsbuildinfo, sodass sowohl der TypeScript-eigene inkrementelle Cache als auch der Remote-Cache des Orchestrators greifen.
Wichtig ist dabei, die .tsbuildinfo-Dateien als Build-Ausgabe im Orchestrator zu deklarieren, sonst wird bei einem Cache-Hit zwar der kompilierte Output wiederhergestellt, nicht aber der Zustand, den tsc --build für seine eigene Inkrementalität benötigt.
9. Migrationsstrategie für bestehende Monorepos
Eine schrittweise Migration beginnt an den Blättern des Abhängigkeitsgraphen: Pakete ohne interne Abhängigkeiten erhalten zuerst composite: true, danach folgen Pakete, die nur bereits migrierte Pakete referenzieren.
Ein realistischer Zeitrahmen für ein Repository mit einem Dutzend Paketen liegt bei wenigen Arbeitstagen, wobei der größte Aufwand meist nicht im Setup selbst liegt, sondern im Aufräumen historisch gewachsener, unsauberer Paketgrenzen, die vorher nie explizit gemacht werden mussten.
Die folgende Tabelle vergleicht die wichtigsten Ansätze für Monorepo-Builds und hilft bei der Entscheidung, wann sich der Migrationsaufwand lohnt.
| Ansatz | Inkrementell | Cross-Package-Typsicherheit | Setup-Aufwand |
|---|---|---|---|
| Ein tsconfig fürs ganze Repo | Nein | Ja, aber langsam | Minimal |
| Path Mapping ohne References | Nein | Nur oberflächlich | Gering |
| Project References + tsc -b | Ja, über .tsbuildinfo | Ja, strukturell erzwungen | Mittel |
| Project References + Turborepo/Nx | Ja, plus Remote-Cache | Ja | Hoch, aber skaliert |
Mironsoft
TypeScript-Migration, Typsicherheit und Team-Onboarding
JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?
Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.
Migrations-Fahrplan
Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.
Strict-Mode-Einführung
tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.
Team-Onboarding
Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.
10. Zusammenfassung
TS Project References
Kernmechanismus
composite + references teilen ein Repo in eigenständige Kompilationseinheiten.
Geschwindigkeit
.tsbuildinfo macht unveränderte Pakete zu Millisekunden-Builds.
IDE-Komfort
declarationMap bringt Go-to-Definition zurück in den Quellcode.
Ergänzung
Turborepo/Nx orchestrieren, Project References sichern Korrektheit.