Composite Builds, tsc -b und saubere Paketgrenzen
Wer TypeScript in einem Monorepo ohne Project References einsetzt, wartet bei jeder Änderung auf eine komplette Neuprüfung des gesamten Codes und verliert echte Typsicherheit zwischen Paketen. Project References, composite Builds und eine gemeinsame Basis-Konfiguration schaffen einen klaren Abhängigkeitsgraphen, schnelle inkrementelle Builds und verlässliche Grenzen zwischen Paketen, auch bei wachsender Paketanzahl.
Inhaltsverzeichnis
- 1. Warum Monorepos eigene TypeScript-Strategien brauchen
- 2. Project References im Detail: composite, references, prepend
- 3. Composite Builds und Build-Reihenfolge mit tsc -b
- 4. Eine gemeinsame tsconfig.base.json für alle Pakete
- 5. Path Aliases vs. Project References
- 6. Deklarationsdateien (.d.ts) und Output-Struktur
- 7. Integration mit pnpm Workspaces, Turborepo und Nx
- 8. Incremental Build Caching und Performance
- 9. Typische Monorepo-Fallstricke im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Monorepos eigene TypeScript-Strategien brauchen
In einem Monorepo mit mehreren TypeScript-Paketen wächst die Codebasis schneller, als die Grenzen zwischen den Paketen sauber bleiben. Ohne Project References behandelt der TypeScript-Compiler das gesamte Repository standardmäßig als einen einzigen Compilation-Kontext: Jede Änderung in einer tief verschachtelten Datei kann potenziell eine Typprüfung des kompletten Projekts auslösen, selbst wenn nur ein einziges Paket tatsächlich betroffen ist. Bei zehn oder zwanzig Paketen wird dieser naive Ansatz spürbar langsam, sowohl im Editor mit dem TS-Language-Server als auch in der CI-Pipeline.
Project References lösen dieses Problem, indem sie jedes Paket zu einer eigenständigen TypeScript-Projekteinheit machen, mit eigener tsconfig.json, eigenem Build-Output und einem explizit deklarierten Abhängigkeitsgraphen zu anderen Paketen. Der Compiler kennt dadurch die Reihenfolge, in der Pakete gebaut werden müssen, und kann Typinformationen aus bereits kompilierten Deklarationsdateien wiederverwenden, statt Quellcode aus anderen Paketen erneut zu parsen. Das Ergebnis ist ein Setup, das mit der Anzahl der Pakete skaliert, statt linear langsamer zu werden.
2. Project References im Detail: composite, references, prepend
Ein Paket wird zu einem "composite project", sobald in seiner tsconfig.json compilerOptions.composite auf true gesetzt ist. composite erzwingt automatisch mehrere Bedingungen: declaration muss aktiviert sein, damit andere Pakete Typinformationen ohne Zugriff auf den Quellcode konsumieren können, rootDir und include müssen so gesetzt sein, dass der Compiler exakt weiß, welche Dateien zum Projekt gehören, und alle referenzierten Dateien müssen innerhalb dieser Grenze liegen. Ein Paket, das composite: true setzt, kann nicht mehr beliebig auf Dateien außerhalb seines rootDir zugreifen.
Die eigentliche Verknüpfung erfolgt über das references-Array: { "path": "../core" } teilt dem Compiler mit, dass dieses Paket von packages/core abhängt. Beim Bauen des abhängigen Pakets prüft tsc zuerst, ob core aktuell ist, und baut es bei Bedarf zuerst. Die seltener genutzte Option prepend fügt den kompilierten Output der referenzierten Projekte direkt in die Ausgabedatei ein, was nur bei outFile-basierten Bundles relevant ist und in modernen ESM-Setups mit separaten Output-Verzeichnissen praktisch nie benötigt wird.
// File: packages/core/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src"],
"references": []
}
// File: packages/api/tsconfig.json
// api depends on core, so it lists core in "references"
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src"],
"references": [
{ "path": "../core" }
]
}
3. Composite Builds und Build-Reihenfolge mit tsc -b
tsc -b, der Build-Modus des TypeScript-Compilers, unterscheidet sich fundamental vom normalen tsc-Aufruf. Statt eine einzelne tsconfig.json zu kompilieren, liest tsc -b den kompletten references-Graphen, sortiert die Pakete topologisch und baut sie in genau der Reihenfolge, in der ihre Abhängigkeiten es erfordern. Ein Paket wird nur dann neu gebaut, wenn sich seine Quelldateien oder eine seiner Abhängigkeiten seit dem letzten Build geändert haben. Dieser Zustand wird pro Paket in einer .tsbuildinfo-Datei festgehalten, die Zeitstempel, Dateihashes und die verwendeten Compiler-Optionen speichert.
In der Praxis reicht ein einzelner Befehl wie npx tsc -b packages/api, um den gesamten Abhängigkeitsbaum korrekt zu bauen: TypeScript baut automatisch zuerst packages/core, wenn api davon abhängt. Das Flag --force erzwingt einen vollständigen Rebuild unabhängig vom Cache-Zustand, --clean entfernt alle Build-Artefakte und .tsbuildinfo-Dateien, und --watch überwacht alle referenzierten Projekte gleichzeitig und baut bei Änderungen nur die betroffenen Pakete neu, in korrekter Reihenfolge.
# Build all packages in correct topological order,
# starting from the given project as the entry point
npx tsc -b packages/api
# Force a full rebuild, ignoring the .tsbuildinfo state
npx tsc -b packages/api --force
# Remove all build outputs and .tsbuildinfo files
npx tsc -b packages/api --clean
# Watch mode: rebuild only what changed, respecting the reference graph
npx tsc -b packages/api --watch
# Verbose output to see which projects were skipped as up to date
npx tsc -b packages/api --verbose
4. Eine gemeinsame tsconfig.base.json für alle Pakete
Ohne eine gemeinsame Basis-Konfiguration wiederholt sich derselbe Block an compilerOptions in jedem einzelnen Paket, was bei jeder Anpassung an strict-Regeln oder Zielversion in zehn verschiedenen Dateien synchron gehalten werden muss. Eine tsconfig.base.json im Repo-Root definiert alle geteilten Optionen wie target, strict, esModuleInterop, skipLibCheck und moduleResolution zentral. Jedes Paket erweitert diese Basis mit extends: "../../tsconfig.base.json" und überschreibt nur die paketspezifischen Werte wie outDir, rootDir und tsBuildInfoFile.
Wichtig dabei: composite und declaration müssen nicht zwingend schon in der Basis stehen, wenn nicht jedes Paket ein composite project sein soll, etwa bei einem reinen Test- oder Tooling-Paket ohne eigene Konsumenten. Eine zweite, kleinere Zwischenschicht wie eine paketspezifische tsconfig.build.json kann zusätzliche Build-Optionen wie declarationMap oder sourceMap aktivieren, während eine tsconfig.json mit test-relevanten Includes für Editor und Testrunner separat gehalten wird. Diese Trennung verhindert, dass Testdateien versehentlich in den Produktions-Build gelangen.
// File: tsconfig.base.json (repo root)
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"composite": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
}
}
5. Path Aliases vs. Project References
Path Aliases über die paths-Option in compilerOptions lösen ein anderes Problem als Project References: Sie ändern lediglich, wie der Compiler und der Editor einen Importpfad wie @mironsoft/core innerhalb eines einzigen TypeScript-Programms auflösen. paths hat keinerlei Einfluss auf die erzeugte JavaScript-Ausgabe, keinen Effekt auf die Build-Reihenfolge und erzwingt keine Grenze zwischen Paketen. Ein Paket kann über einen Alias theoretisch auf interne, nicht exportierte Dateien eines anderen Pakets zugreifen, weil paths schlicht ein Dateipfad-Mapping ist, keine Modulgrenze.
Project References lösen das strukturelle Problem: composite project plus references erzwingt, dass ein Paket nur über seine tatsächlich exportierten Deklarationsdateien konsumiert wird, und der Build bricht ab, wenn ein Paket versucht, ein anderes zu importieren, das nicht explizit in references eingetragen ist. In der Praxis kombiniert man beides: paths für kurze, komfortable Importpfade im Editor, Project References für den tatsächlichen Build- und Typgraphen. Bundler wie esbuild oder Vite lösen paths zur Build-Zeit selbst auf, benötigen dafür aber ein Plugin wie vite-tsconfig-paths, weil sie tsconfig.json nicht nativ respektieren.
6. Deklarationsdateien (.d.ts) und Output-Struktur
Damit ein Paket in einem Monorepo von einem anderen konsumiert werden kann, ohne dessen Quellcode erneut zu parsen, muss declaration: true gesetzt sein. Der Compiler erzeugt dann neben der kompilierten .js-Datei eine .d.ts-Datei mit derselben Verzeichnisstruktur im outDir. Konsumierende Pakete lesen ausschließlich diese Deklarationsdateien, was den Type-Check erheblich beschleunigt, weil TypeScript keine vollständige Re-Analyse des fremden Quellcodes mehr durchführen muss, sondern nur die bereits aufgelösten Signaturen einliest.
declarationMap: true erzeugt zusätzlich .d.ts.map-Dateien, die "Go to Definition" im Editor direkt zur ursprünglichen .ts-Quelldatei springen lassen, statt zur generierten Deklaration, was die Entwicklererfahrung in einem Monorepo erheblich verbessert. Im package.json jedes Pakets sollte das Feld types (oder exports mit einer types-Condition) exakt auf die erzeugte .d.ts-Datei im dist-Verzeichnis zeigen, nicht auf die Quelldatei. Ein häufiger Fehler ist, main und types im package.json auf src statt dist zu verweisen, wodurch Konsumenten ungebaute Quelldateien importieren und der ganze Vorteil des composite Builds verloren geht.
7. Integration mit pnpm Workspaces, Turborepo und Nx
pnpm Workspaces bilden das Fundament für die Paketverwaltung im Monorepo: Über das workspace:-Protokoll in package.json verweisen Pakete aufeinander mit exakter, aber lokal symlink-basierter Auflösung, ohne dass Versionen aus der npm-Registry gezogen werden müssen. TypeScript Project References und pnpm Workspaces lösen dabei unterschiedliche, komplementäre Probleme: pnpm sorgt dafür, dass node_modules korrekt verlinkt ist, Project References sorgen dafür, dass der Compiler die Build-Reihenfolge und die Typgrenzen kennt. Beide Konfigurationen sollten synchron gehalten werden, jede Abhängigkeit in package.json sollte auch als references-Eintrag existieren.
Turborepo und Nx bauen auf dieser Struktur auf, indem sie eigene Task-Graphen mit dependsOn: ["^build"] definieren, was bedeutet, dass die build-Aufgabe eines Pakets erst startet, wenn alle Abhängigkeiten fertig gebaut sind. Praktisch spiegelt das exakt die Reihenfolge, die tsc -b ohnehin über den references-Graphen berechnet, allerdings mit zusätzlichem Remote Caching über eine Cloud oder ein selbst gehostetes Cache-Backend. Nx kann den Projekt-Graphen sogar automatisch aus den Import-Statements ableiten und mit der tsconfig-Struktur abgleichen, was inkonsistente references-Einträge frühzeitig aufdeckt.
// File: turbo.json (repo root)
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}
8. Incremental Build Caching und Performance
Der entscheidende Baustein für inkrementelle Builds ist die .tsbuildinfo-Datei, die tsc -b pro Paket im outDir ablegt. Sie enthält Hashes aller Quelldateien, die verwendeten Compiler-Optionen und die Signaturen der exportierten Typen. Bei jedem erneuten Build vergleicht TypeScript diese Hashes: Hat sich weder eine Quelldatei noch eine referenzierte Abhängigkeit geändert, überspringt der Compiler das Paket komplett, ohne auch nur eine Datei zu öffnen. incremental: true muss dafür zusätzlich zu composite gesetzt sein, wobei composite incremental automatisch impliziert.
Turborepo und Nx erweitern dieses lokale Caching um Remote Caching: Ein Hash aus Quelldateien, Lockfile-Version und Compiler-Optionen wird als Cache-Key verwendet, und bei einem Treffer wird der komplette dist-Ordner samt .tsbuildinfo aus einem gemeinsamen Cache heruntergeladen, statt neu zu bauen. Das ist besonders in CI-Pipelines wirkungsvoll, wenn mehrere Branches denselben unveränderten Paketstand teilen. Wichtig: Wird die .tsbuildinfo-Datei versehentlich ins Git-Repository eingecheckt statt in .gitignore aufgenommen, führt das zu inkonsistenten Build-Zuständen zwischen Entwicklerrechnern, weil absolute Pfade und Zeitstempel maschinenspezifisch sind.
9. Typische Monorepo-Fallstricke im Vergleich
Der häufigste Fehler in wachsenden Monorepos ist eine zirkuläre Abhängigkeit zwischen Paketen: Paket A importiert einen Typ aus Paket B, während B gleichzeitig etwas aus A importiert. TypeScript verweigert in diesem Fall den Build mit der Fehlermeldung "Project references may not form a circular graph" (TS6202). Die Lösung ist fast immer dieselbe: den gemeinsam benötigten Typ oder die gemeinsame Logik in ein drittes, abhängigkeitsfreies Paket extrahieren, auf das beide Seiten referenzieren, statt sich gegenseitig zu referenzieren.
Ein zweites, subtileres Problem sind inkonsistente TypeScript-Versionen zwischen Paketen. Wenn pnpm oder npm in unterschiedlichen Paketen unterschiedliche typescript-Versionen aus den devDependencies hoisten oder installieren, können identische .d.ts-Dateien je nach verwendeter Compiler-Version leicht unterschiedlich interpretiert werden, was zu Typfehlern führt, die sich nicht reproduzieren lassen. Die Lösung: typescript als einzige Version im Root-package.json deklarieren, in pnpm mit dem Feld pnpm.overrides oder mit Tools wie syncpack erzwingen, und in CI mit tsc --version an zentraler Stelle prüfen, dass alle Pakete tatsächlich dieselbe Compiler-Version verwenden.
// File: packages/core/src/index.ts
// WRONG: core imports from api, but api already references core -> cycle
import type { ApiResponse } from '@mironsoft/api';
// error TS6202: Project references may not form a circular graph.
export interface CoreEntity {
id: string;
}
// RIGHT: extract the shared type into a third, dependency-free package
// File: packages/shared-types/src/index.ts
export interface CoreEntity {
id: string;
}
export interface ApiResponse<T> {
data: T;
meta: { requestId: string };
}
// packages/core and packages/api both reference packages/shared-types,
// but never reference each other -> no cycle
| Aspekt | Ohne Project References | Mit Project References (composite) | Effekt |
|---|---|---|---|
| Type-Check bei Änderung | Kompletter Repo-Check | Nur betroffene Pakete via tsc -b | Deutlich schnellere Feedback-Zyklen |
| Paketgrenzen | Nur Konvention (paths) | Vom Compiler erzwungen | Keine versehentlichen internen Imports |
| Zirkuläre Abhängigkeiten | Werden erst zur Laufzeit sichtbar | Build bricht sofort mit TS6202 ab | Frühzeitige, klare Fehlermeldung |
| CI-Caching | Kein Zwischenstand, jedes Mal alles | .tsbuildinfo + Remote Cache | Kürzere CI-Laufzeiten |
| Editor-Performance | Ein TS-Server prüft alles | References isolieren den Sprachserver-Scope | Reaktionsfähiger Editor bei vielen Paketen |
In der Praxis hängen diese Fallstricke oft zusammen: Ein Team, das zirkuläre Referenzen durch saubere Paketgrenzen vermeidet, hat in der Regel auch weniger Probleme mit inkonsistenten TypeScript-Versionen, weil beide Disziplinen von derselben Grundhaltung profitieren, nämlich Abhängigkeiten explizit statt implizit zu modellieren. Wer die Empfehlungen aus der Tabelle konsequent umsetzt, gewinnt sowohl schnellere Builds als auch verlässlichere Typprüfungen über das gesamte Monorepo hinweg.
Mironsoft
TypeScript-Tooling, Frontend-Architektur und typsichere Headless-Integrationen
TypeScript-Monorepo mit sauberer Build-Architektur?
Wir strukturieren bestehende oder neue Monorepos mit Project References, richten composite Builds mit tsc -b ein und integrieren Turborepo oder Nx für schnelle, gecachte CI-Pipelines, inklusive gemeinsamer tsconfig.base.json und sauberer Paketgrenzen.
Monorepo-Audit
Analyse bestehender tsconfig-Struktur, zirkuläre Referenzen und inkonsistente TS-Versionen aufspüren
Project-References-Setup
composite Builds, tsc -b Build-Reihenfolge und gemeinsame Basis-Konfiguration einrichten
CI/CD-Integration
Turborepo oder Nx mit Remote Caching für schnelle, reproduzierbare Pipelines konfigurieren
10. Zusammenfassung
TypeScript Project References lösen das Kernproblem wachsender Monorepos: eine explizit deklarierte, vom Compiler erzwungene Abhängigkeitsstruktur zwischen Paketen statt loser Konventionen. composite: true plus ein references-Array machen jedes Paket zu einer eigenständigen Build-Einheit, tsc -b baut diese Einheiten topologisch sortiert und überspringt unveränderte Pakete automatisch über die .tsbuildinfo-Datei. Eine gemeinsame tsconfig.base.json hält geteilte Compiler-Optionen zentral, während jedes Paket nur outDir, rootDir und tsBuildInfoFile individuell setzt.
Path Aliases bleiben dabei sinnvoll für kurze Importpfade im Editor, ersetzen aber nicht die strukturelle Absicherung durch Project References. Die größten Stolperfallen, zirkuläre Referenzen und inkonsistente TypeScript-Versionen zwischen Paketen, lassen sich mit klaren Regeln vermeiden: gemeinsame Typen in dependency-freie Pakete extrahieren und eine einzige, im gesamten Repository erzwungene TypeScript-Version. Wer diese Bausteine kombiniert, bekommt ein Monorepo, das mit der Anzahl der Pakete skaliert, statt bei jedem Build langsamer zu werden.
TypeScript in Monorepos: Project References - Das Wichtigste auf einen Blick
Project References
composite: true + references-Array erzwingen einen expliziten, vom Compiler geprüften Abhängigkeitsgraphen zwischen Paketen.
tsc -b Build-Reihenfolge
Baut Pakete topologisch sortiert, überspringt unveränderte Pakete automatisch über .tsbuildinfo.
Gemeinsame Basis-Konfiguration
tsconfig.base.json zentral pflegen, pro Paket nur outDir, rootDir und tsBuildInfoFile überschreiben.
Typische Fallstricke
Zirkuläre Referenzen früh erkennen (TS6202), TypeScript-Version über alle Pakete hinweg synchron halten.