TypeScript-Monorepos mit pnpm Workspaces sauber strukturieren
AI generated
type
TypeScript
TypeScript-Monorepos mit pnpm
Workspaces sauber strukturieren, ohne Phantom-Dependencies

pnpm Workspaces organisieren mehrere TypeScript-Pakete in einem Repository, ohne die Dependency-Duplizierung von npm oder das großzügige Hoisting von Yarn Classic. Wer ein Monorepo neu aufsetzt oder ein bestehendes migriert, profitiert von einem strikteren, aber vorhersehbareren Dependency-Baum.

10 Min. Lesezeit pnpm Monorepo

1. Warum pnpm Workspaces für TypeScript-Monorepos

pnpm speichert jedes installierte Paket genau einmal in einem globalen, inhaltsadressierten Store und verlinkt es über harte Links in die einzelnen node_modules-Ordner der Pakete. Das spart Plattenplatz und beschleunigt Installationen erheblich, besonders bei vielen Paketen mit überlappenden Abhängigkeiten.

Wichtiger für TypeScript-Monorepos ist aber die strikte node_modules-Struktur: Ein Paket sieht standardmäßig nur die Abhängigkeiten, die es tatsächlich selbst deklariert hat, nicht die transitiv installierten Pakete eines Nachbarpakets.

Das verhindert sogenannte Phantom-Dependencies, bei denen Code kompiliert und läuft, weil ein Paket zufällig über ein anderes Paket im Baum verfügbar ist, aber bricht, sobald diese zufällige Verfügbarkeit sich ändert. Gerade in großen TypeScript-Monorepos mit vielen Teams ist dieser Effekt schwer zu debuggen, weil der Fehler oft erst Monate später bei einem scheinbar unabhängigen Update auftritt.

2. pnpm-workspace.yaml und Paketstruktur

Die Datei pnpm-workspace.yaml im Repository-Root definiert, welche Verzeichnisse als Workspace-Pakete gelten, meist über Glob-Muster wie packages/* und apps/*. Jedes gefundene Verzeichnis mit eigener package.json wird zu einem eigenständigen Paket im Workspace.

Eine typische Struktur trennt wiederverwendbare Bibliotheken unter packages/ von eigenständigen Anwendungen unter apps/, sodass etwa ein UI-Komponenten-Paket, ein Utility-Paket und eine Webanwendung im selben Repository koexistieren, aber unabhängig versioniert und getestet werden können.

Jedes Paket bekommt seine eigene tsconfig.json, package.json und, falls nötig, eigene Build-Skripte, während gemeinsame Tooling-Konfiguration wie ESLint oder Prettier zentral im Root liegt und von allen Paketen referenziert wird.


# pnpm-workspace.yaml
packages:
  - "packages/*"
  - "apps/*"
  - "!**/test/**"

3. Cross-Package-Referenzen mit TypeScript Project References

Damit ein Paket die Typen eines anderen Workspace-Pakets korrekt auflöst und dabei inkrementell kompiliert, statt bei jeder Änderung das gesamte Monorepo neu zu bauen, kommen TypeScript Project References über references in tsconfig.json zum Einsatz.

Jedes referenzierte Paket muss composite: true in seinen compilerOptions setzen, damit TypeScript Deklarationsdateien und einen Build-Info-Cache erzeugt, den abhängige Pakete für die eigene Typprüfung wiederverwenden können.

Der Befehl tsc --build, kurz tsc -b, respektiert die Abhängigkeitsreihenfolge zwischen den referenzierten Projekten automatisch und baut nur die Pakete neu, deren Quellcode oder Abhängigkeiten sich seit dem letzten Lauf geändert haben.


// packages/api-client/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist"
  },
  "references": [{ "path": "../shared-types" }]
}

4. Eine geteilte tsconfig-Basis über extends

Statt compilerOptions in jedem Paket separat zu pflegen, definiert eine zentrale tsconfig.base.json im Root gemeinsame Einstellungen wie strict, target oder moduleResolution, die jedes Paket über extends übernimmt.

Paketspezifische Abweichungen, etwa unterschiedliche lib-Einstellungen für ein Node.js-Backend-Paket gegenüber einem React-Frontend-Paket, werden lokal in der jeweiligen tsconfig.json überschrieben, ohne die gemeinsame Basis zu verändern.

Diese Struktur reduziert Konfigurationsdrift zwischen Paketen erheblich: Eine Änderung an der Basis, etwa eine strengere Compiler-Option, wirkt sofort auf das gesamte Monorepo, statt in jedem Paket einzeln nachgezogen werden zu müssen.

5. Dependency Management mit dem workspace-Protokoll

Statt eine Versionsnummer aus der npm-Registry anzugeben, referenziert "@acme/shared-types": "workspace:*" in package.json direkt das lokale Paket im selben Repository, unabhängig von dessen veröffentlichter Version.

Beim Publishen ersetzt pnpm das workspace:-Protokoll automatisch durch die tatsächliche Versionsnummer des referenzierten Pakets, sodass veröffentlichte Pakete korrekte, auflösbare Abhängigkeiten enthalten, ohne dass Entwickler das manuell nachpflegen müssen.

Varianten wie workspace:^ oder workspace:~ steuern dabei, welches Semver-Range-Präfix beim Publishen eingesetzt wird, während lokal während der Entwicklung immer die aktuelle Arbeitskopie verwendet wird, ganz ohne erneute Installation nach jeder Änderung.


// apps/web/package.json
{
  "dependencies": {
    "@acme/shared-types": "workspace:*",
    "@acme/ui": "workspace:^"
  }
}

6. Build-Orchestrierung: tsc -b, Turborepo oder Nx

Für kleinere Monorepos genügt tsc --build auf das Root-Projekt mit allen referenzierten Paketen, um in korrekter Reihenfolge inkrementell zu bauen. Der eingebaute Build-Cache von TypeScript reicht hier oft völlig aus.

Sobald Build-Zeiten oder die Anzahl paralleler Skripte wachsen, übernehmen Tools wie Turborepo oder Nx die Orchestrierung: Sie erkennen den Abhängigkeitsgraphen zwischen Workspace-Paketen automatisch aus package.json und cachen Build-Ergebnisse remote, teamweit, statt nur lokal.

Beide Ansätze lassen sich kombinieren: tsc -b übernimmt die eigentliche Typprüfung und Kompilierung pro Paket, während Turborepo oder Nx lediglich die Reihenfolge, Parallelisierung und das Caching über alle Skripte im Monorepo steuern.

7. Publishing und Versionierung mit Changesets

Für Monorepos, die einzelne Pakete tatsächlich auf npm veröffentlichen, hat sich Changesets als De-facto-Standard etabliert: Jede Änderung erhält eine kleine Markdown-Datei, die beschreibt, welche Pakete betroffen sind und ob es sich um ein Patch-, Minor- oder Major-Release handelt.

Beim Release-Lauf sammelt Changesets alle offenen Changeset-Dateien, berechnet daraus die neuen Versionsnummern unter Berücksichtigung des workspace:-Protokolls und aktualisiert automatisch alle betroffenen package.json-Dateien samt CHANGELOG.

Das funktioniert speziell mit pnpm zuverlässig, weil die strikte Dependency-Auflösung sicherstellt, dass ein veröffentlichtes Paket wirklich nur die Abhängigkeiten enthält, die es deklariert hat, statt versehentlich von einem Hoisting-Nachbarn zu profitieren.

8. CI-Caching-Strategien für schnelle Pipelines

pnpm bringt über pnpm fetch und den content-addressierten Store bereits eine effiziente Grundlage für CI-Caching: Der globale Store lässt sich zwischen Pipeline-Läufen cachen, wodurch wiederholte Installationen fast ausschließlich aus dem lokalen Cache bedient werden.

Für die eigentliche Build- und Testausführung lohnt sich zusätzliches Caching auf Task-Ebene, etwa über Turborepo Remote Caching, das Build-Ergebnisse anhand eines Hashes aus Quellcode und Abhängigkeiten wiederverwendet, sobald sich an einem Paket nichts geändert hat.

In der Praxis reduziert die Kombination aus pnpm-Store-Caching und Task-Level-Caching die CI-Laufzeit in wachsenden Monorepos oft um mehr als die Hälfte, weil unveränderte Pakete weder neu installiert noch neu gebaut oder getestet werden müssen, was sich besonders bei häufigen, kleinen Pull-Requests auszahlt.

9. Häufige Fallstricke: Hoisting, Peer Dependencies, zirkuläre Referenzen

Die strikte node_modules-Struktur von pnpm deckt Phantom-Dependencies zuverlässig auf, führt aber gelegentlich zu Fehlern bei Paketen, die intern selbst inkorrekt auf transitiv verfügbare Abhängigkeiten vertrauen, statt sie explizit zu deklarieren.

Peer Dependencies verlangen in einem strikten Setup, dass jedes Paket, das eine Peer Dependency erwartet, auch tatsächlich eine kompatible Version im Workspace vorfindet. Fehlt sie, meldet pnpm das explizit als Warnung, statt sie stillschweigend über Hoisting zu ignorieren.

Zirkuläre Abhängigkeiten zwischen Workspace-Paketen, etwa wenn Paket A Paket B importiert und umgekehrt, werden von tsc --build als Fehler erkannt und müssen vor dem ersten erfolgreichen Build aufgelöst werden, meist durch Extraktion der gemeinsam genutzten Typen in ein drittes, gemeinsames Paket, das beide ursprünglichen Pakete unabhängig voneinander importieren können.

Merkmal pnpm Workspaces npm Workspaces Yarn Workspaces
node_modules-Struktur Strikt, keine Phantom-Dependencies Flach gehoistet, anfällig für Phantom-Deps Flach gehoistet (Classic), strikt (Berry PnP)
Plattenverbrauch Gering, über globalen Store per Hardlink Hoch, Duplikate je Projekt möglich Mittel bis gering, je nach Modus
workspace-Protokoll workspace:* nativ unterstützt Unterstützt seit npm 7 Unterstützt seit Yarn Berry
Installationsgeschwindigkeit Sehr schnell durch Caching und Hardlinks Mittel Schnell mit Plug'n'Play, sonst mittel
Monorepo-Tooling-Integration Sehr gut mit Turborepo, Nx, Changesets Gut, etwas mehr manuelle Konfiguration Gut, PnP kann Kompatibilitätsprobleme verursachen

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

pnpm Workspaces

Keine Phantom-Deps

Strikte node_modules-Struktur pro Paket

workspace:-Protokoll

Lokale Referenz statt Registry-Version

Project References

tsc -b für inkrementelle Builds

Weniger Plattenplatz

Ein globaler Store statt Duplikate

11. FAQ: pnpm Workspaces

1Was unterscheidet pnpm Workspaces grundlegend von npm Workspaces?
pnpm hält jedes Paket in einem globalen Store und verlinkt es strikt pro Paket, während npm Abhängigkeiten flach in ein gemeinsames node_modules hoisted, was Phantom-Dependencies deutlich stärker begünstigt als bei pnpm.
2Was ist eine Phantom-Dependency und warum verhindert pnpm sie?
Eine Phantom-Dependency ist ein Paket, das im Code funktioniert, obwohl es nicht deklariert wurde, weil es zufällig über Hoisting verfügbar ist. pnpms strikte Struktur macht solche Zugriffe unmöglich.
3Wofür steht workspace:* in package.json?
Es referenziert ein anderes Paket im selben Workspace unabhängig von dessen veröffentlichter Version. Beim Publishen ersetzt pnpm es automatisch durch die tatsächliche Versionsnummer.
4Brauche ich TypeScript Project References für ein pnpm-Monorepo?
Zwingend nötig sind sie nicht, aber sie ermöglichen inkrementelle, reihenfolgekorrekte Builds über tsc -b und verhindern, dass bei jeder Änderung das gesamte Monorepo neu kompiliert werden muss.
5Wie teile ich eine gemeinsame tsconfig über mehrere Pakete?
Über eine zentrale tsconfig.base.json im Root, die jedes Paket via extends einbindet und lokal nur paketspezifische Abweichungen überschreibt.
6Brauche ich zusätzlich Turborepo oder Nx bei pnpm Workspaces?
Für kleine Monorepos reicht tsc -b oft aus. Bei wachsender Paketzahl und Build-Zeit lohnen sich Turborepo oder Nx für Task-Orchestrierung, Parallelisierung und Remote-Caching über das gesamte Team hinweg.
7Wie funktioniert Publishing einzelner Pakete aus einem pnpm-Monorepo?
Changesets hat sich als Standard etabliert: Jede Änderung erhält eine Markdown-Datei, aus der beim Release automatisch Versionsnummern und CHANGELOG-Einträge generiert werden.
8Wie cache ich pnpm-Installationen in der CI-Pipeline effizient?
Der globale pnpm-Store lässt sich zwischen Pipeline-Läufen cachen, wodurch wiederholte Installationen größtenteils lokal bedient werden, ohne erneuten Download aus der Registry.
9Was passiert bei zirkulären Abhängigkeiten zwischen Workspace-Paketen?
tsc --build erkennt zirkuläre Referenzen als Fehler. Die übliche Lösung ist, gemeinsam genutzte Typen in ein drittes, unabhängiges Paket auszulagern, das beide ursprünglichen Pakete importieren.
10Sind Peer Dependencies in pnpm Workspaces ein Problem?
Sie erfordern eine explizit kompatible Version im Workspace. Fehlt sie, meldet pnpm eine deutliche Warnung, statt sie wie bei flachem Hoisting stillschweigend zu ignorieren und den Fehler erst zur Laufzeit sichtbar zu machen.