für interne TypeScript-Pakete im Monorepo
Ein internes TypeScript-Paket, das im Monorepo importiert wird, aber beim Bundeln der Anwendung plötzlich Type-Errors oder falsche Runtime-Module lädt, hat fast immer dieselbe Ursache: ein fehlerhaft konfiguriertes exports-Feld. Wer die Feinheiten von Conditional Exports und typesVersions versteht, vermeidet diese Fehlerklasse dauerhaft.
Inhaltsverzeichnis
- 1. Warum das exports-Feld heute Pflicht ist
- 2. Grundstruktur: main, types und exports im Zusammenspiel
- 3. Conditional Exports: import, require und types
- 4. Die Reihenfolge der Bedingungen entscheidet
- 5. Subpath-Exports für granulare Paket-Struktur
- 6. typesVersions für ältere TypeScript-Versionen
- 7. Das Dual-Package-Hazard-Problem verstehen
- 8. Debugging: Module Resolution nachvollziehen
- 9. Export-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum das exports-Feld heute Pflicht ist
Vor der Einführung des exports-Feldes bestimmte allein das main-Feld in package.json, welche Datei beim Import eines Pakets geladen wird, während beliebige interne Dateipfade wie @myorg/utils/dist/internal/helper.js ebenfalls direkt importierbar blieben. Für ein TypeScript-Monorepo mit vielen internen Paketen führte das dazu, dass Implementierungsdetails versehentlich zur öffentlichen API wurden, weil niemand daran gehindert wurde, tief in interne Verzeichnisse zu importieren.
Das exports-Feld löst dieses Problem, indem es explizit definiert, welche Einstiegspunkte ein Paket überhaupt anbietet. Jeder Importpfad, der nicht im exports-Feld aufgeführt ist, wirft beim Import einen Fehler, unabhängig davon, ob die Datei physisch existiert. Für interne Pakete in einem TypeScript-Monorepo ist das ein Segen für die Wartbarkeit, weil Refactorings innerhalb eines Pakets möglich werden, ohne dass andere Pakete versehentlich von internen Implementierungsdetails abhängig sind.
2. Grundstruktur: main, types und exports im Zusammenspiel
Für ein einfaches TypeScript-Paket im Monorepo reicht zunächst eine minimale Konfiguration, die einen einzigen Haupteinstiegspunkt definiert. Wichtig ist, dass main und types als Fallback für ältere Tools bestehen bleiben, während exports die moderne, verbindliche Wahrheit für Node.js und aktuelle Bundler darstellt. Tools, die exports nicht unterstützen, etwa sehr alte Node-Versionen, fallen automatisch auf main zurück.
Ein häufiger Fehler in einem wachsenden TypeScript-Monorepo ist, nur exports zu pflegen und main/types zu vergessen oder veralten zu lassen. Manche Editor-Integrationen und ältere Build-Tools lesen weiterhin zuerst main, bevor sie exports auswerten, was zu inkonsistentem Verhalten zwischen Entwicklungsumgebung und Produktionsbuild führen kann, wenn beide Felder auseinanderlaufen.
{
"name": "@myorg/shared-utils",
"version": "1.4.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"]
}
3. Conditional Exports: import, require und types
Conditional Exports erlauben es, für denselben Einstiegspunkt unterschiedliche Dateien je nach Ausführungskontext bereitzustellen. Die Bedingung import greift, wenn ein Konsument import-Syntax verwendet, require greift bei CommonJS-Aufrufen mit require(), und types liefert dem TypeScript-Compiler den passenden Deklarationsdateipfad. Diese Struktur ist der Grund, warum ein einziges TypeScript-Paket in einem TypeScript-Monorepo gleichzeitig von ESM-Konsumenten und älteren CommonJS-Konsumenten genutzt werden kann, ohne zwei getrennte Pakete pflegen zu müssen.
Zusätzlich existieren speziellere Bedingungen wie node für Node.js-spezifischen Code, browser für Bundler, die im Browser-Kontext arbeiten, und development/production für unterschiedliche Builds je nach Umgebung. In einem internen TypeScript-Monorepo lohnt sich diese Feindifferenzierung meist erst, wenn ein Paket tatsächlich unterschiedliches Verhalten je nach Umgebung braucht, etwa weil eine Debug-Variante zusätzliche Warnungen ausgibt, die in Produktion entfernt werden sollen.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": {
"import": "./dist/node.js",
"require": "./dist/node.cjs"
},
"browser": "./dist/browser.js",
"development": "./dist/index.dev.js",
"default": "./dist/index.js"
}
}
}
4. Die Reihenfolge der Bedingungen entscheidet
Ein subtiler, aber häufiger Fehler in Conditional Exports ist die falsche Reihenfolge der Schlüssel innerhalb eines Bedingungsobjekts. Node.js wertet die Bedingungen in genau der Reihenfolge aus, in der sie in package.json geschrieben sind, und nimmt die erste passende. Steht types nicht an erster Stelle, kann es passieren, dass ein Bundler bereits bei import oder default landet, bevor der TypeScript-Compiler die korrekte Deklarationsdatei findet, was zu any-typisierten Importen ohne erkennbare Fehlermeldung führt.
Die verlässliche Regel für ein TypeScript-Monorepo lautet daher: types immer als ersten Schlüssel in jedem Bedingungsobjekt platzieren, gefolgt von spezifischeren Laufzeitbedingungen wie node oder browser, und default als letzten, allgemeinsten Fallback. TypeScript selbst prüft diese Reihenfolge nicht automatisch, weshalb ein ESLint-Plugin wie eslint-plugin-package-json oder ein manueller Review-Schritt beim Anlegen neuer Pakete sinnvoll ist.
5. Subpath-Exports für granulare Paket-Struktur
Größere interne Pakete in einem TypeScript-Monorepo bieten oft mehr als einen sinnvollen Einstiegspunkt an, etwa getrennte Module für Validierung, Formatierung und Datumsberechnung. Subpath-Exports erlauben genau das: Statt alles über den Haupteinstiegspunkt . zu bündeln, was unnötig große Bundle-Größen erzeugen kann, definiert das Paket zusätzliche Pfade wie ./validation oder ./date, die Konsumenten gezielt importieren können.
Ein Platzhalter-Muster mit ./* erlaubt zusätzlich, ganze Verzeichnisse dynamisch zu exportieren, ohne jeden einzelnen Dateipfad manuell aufzulisten. Das ist praktisch für Pakete mit vielen kleinen Modulen, birgt aber das Risiko, versehentlich interne Implementierungsdetails freizugeben, die eigentlich nicht Teil der öffentlichen API sein sollten. Für ein sauber geschnittenes TypeScript-Monorepo-Paket ist eine explizite Liste der Subpath-Exports meist die bessere Wahl als ein pauschaler Wildcard-Export.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./validation": {
"types": "./dist/validation/index.d.ts",
"import": "./dist/validation/index.js"
},
"./date": {
"types": "./dist/date/index.d.ts",
"import": "./dist/date/index.js"
},
"./package.json": "./package.json"
}
}
6. typesVersions für ältere TypeScript-Versionen
Das Feld typesVersions löst ein spezielles Problem: Nicht jedes Team in einem großen TypeScript-Monorepo nutzt zwangsläufig dieselbe TypeScript-Version, und manche neuere TypeScript-Features in Deklarationsdateien werden von älteren Compiler-Versionen nicht verstanden. Mit typesVersions kann ein Paket alternative Deklarationsdateien für bestimmte TypeScript-Versionsbereiche bereitstellen, etwa eine vereinfachte Variante für TypeScript vor Version 5.0.
In der Praxis wird typesVersions seltener benötigt als exports selbst, weil moderne Monorepos meist eine einheitliche TypeScript-Version über alle Pakete hinweg erzwingen, etwa über ein gemeinsames Root-package.json mit einer festen Dependency-Version. Für Paketautoren, die ihre Bibliothek öffentlich auf npm veröffentlichen und eine breite Palette an TypeScript-Versionen unterstützen müssen, bleibt typesVersions jedoch ein wichtiges Werkzeug, um Kompatibilitätsprobleme zu vermeiden.
7. Das Dual-Package-Hazard-Problem verstehen
Ein Dual-Package-Hazard entsteht, wenn ein Paket sowohl über import als auch über require geladen wird und dabei zwei unterschiedliche Modulinstanzen im Speicher landen, statt einer einzigen. Das ist besonders gefährlich bei Paketen, die globalen State halten, etwa einen Singleton für Konfiguration oder einen internen Cache, weil Instanzprüfungen mit instanceof plötzlich fehlschlagen, obwohl scheinbar derselbe Typ verwendet wird.
Für ein internes TypeScript-Monorepo lässt sich dieses Risiko am zuverlässigsten vermeiden, indem konsequent nur ein Modulsystem verwendet wird, meist ESM mit "type": "module" im Root-package.json. Wird aus Kompatibilitätsgründen dennoch ein Dual-Package mit CommonJS- und ESM-Build angeboten, sollte zustandsbehafteter Code, der über beide Ladepfade hinweg konsistent bleiben muss, in ein separates, ausschließlich einfach geladenes Paket ausgelagert werden.
8. Debugging: Module Resolution nachvollziehen
Wenn ein Import trotz korrekt aussehender exports-Konfiguration fehlschlägt, liefert der Node.js-Flag --experimental-loader in Kombination mit NODE_DEBUG=module detaillierte Ausgaben darüber, welche Bedingung tatsächlich ausgewertet wurde und welche Datei am Ende geladen wird. Für Bundler wie Vite oder Webpack existieren eigene Debug-Modi, die anzeigen, welchen Pfad die Modulauflösung im internen TypeScript-Monorepo-Paket tatsächlich genommen hat.
Ein schneller manueller Check, der viele Fälle bereits klärt: node -e "console.log(require.resolve('@myorg/shared-utils'))" für CommonJS-Kontexte, beziehungsweise ein kleines ESM-Testskript mit dynamischem import() für ESM-Kontexte. Zeigt der aufgelöste Pfad auf eine unerwartete Datei, liegt der Fehler fast immer in der Reihenfolge oder den fehlenden Bedingungen im exports-Feld, nicht am eigentlichen Build-Prozess.
# CommonJS: welchen Pfad löst Node tatsächlich auf?
node -e "console.log(require.resolve('@myorg/shared-utils'))"
# ESM: dynamischer Import zum Debuggen der Auflösung
node --input-type=module -e "
import('@myorg/shared-utils').then((m) => console.log(m))
"
# Ausführliche Debug-Ausgabe der Modulauflösung
NODE_DEBUG=module node ./scripts/check-import.mjs
9. Export-Strategien im Vergleich
Je nach Größe und Nutzungskontext eines internen Pakets im TypeScript-Monorepo passt eine andere Export-Strategie besser. Die folgende Übersicht ordnet die gängigsten Ansätze ein.
| Strategie | Wann sinnvoll | Risiko | Empfehlung |
|---|---|---|---|
| Ein einziger Einstiegspunkt | Kleine, fokussierte Pakete | Größeres Bundle bei Teilimport | Für die meisten internen Pakete ausreichend |
| Explizite Subpath-Exports | Pakete mit mehreren fachlichen Modulen | Mehr Pflegeaufwand bei neuen Modulen | Bevorzugt gegenüber Wildcard |
| Wildcard-Subpath (./*) | Sehr viele kleine, generierte Module | Interne Details werden leicht öffentlich | Nur mit klarer Namenskonvention nutzen |
| Dual-Package (ESM+CJS) | Externe npm-Veröffentlichung nötig | Dual-Package-Hazard bei State | Intern meist vermeidbar, nur ESM nutzen |
Für rein interne Pakete in einem TypeScript-Monorepo, die niemals außerhalb des eigenen Repositories konsumiert werden, ist die einfachste tragfähige Lösung meist ein einziger ESM-Einstiegspunkt mit expliziten Subpath-Exports für größere Pakete. Die Komplexität von Dual-Package-Setups mit CommonJS-Fallback lohnt sich fast ausschließlich für Pakete, die zusätzlich öffentlich auf npm veröffentlicht werden.
Mironsoft
TypeScript-Paketstruktur, Module Resolution und Monorepo-Tooling
Rätselhafte Import-Fehler in internen Paketen?
Wir prüfen exports-Konfigurationen in eurem TypeScript-Monorepo, beheben Reihenfolge- und Dual-Package-Probleme und richten eine saubere, wartbare Paketstruktur mit klaren öffentlichen Schnittstellen ein.
Exports-Audit
Bestehende package.json-Konfigurationen auf Fehlerquellen prüfen
Paketstruktur
Subpath-Exports sauber schneiden statt Wildcard-Exports zu nutzen
Module-Resolution-Debugging
Ursachen für falsche Importe schnell und systematisch finden
10. Zusammenfassung
Das exports-Feld ist im modernen TypeScript-Monorepo kein optionales Detail mehr, sondern die verbindliche öffentliche Schnittstelle eines internen Pakets. Es verhindert, dass andere Pakete versehentlich von internen Implementierungsdetails abhängig werden, und macht mit Conditional Exports einen einzigen Codestand für ESM- und CommonJS-Konsumenten nutzbar. Die korrekte Reihenfolge der Bedingungen, mit types immer zuerst, ist dabei der häufigste Stolperstein.
Subpath-Exports strukturieren größere Pakete sauber, sollten aber explizit statt über Wildcards definiert werden, um interne Details nicht versehentlich freizugeben. Dual-Package-Setups mit CommonJS und ESM lohnen sich fast ausschließlich für öffentlich auf npm veröffentlichte Bibliotheken, während rein interne Pakete in einem TypeScript-Monorepo von einem einzigen, konsequent genutzten Modulsystem profitieren.
package.json exports-Feld — Das Wichtigste auf einen Blick
Reihenfolge zählt
types immer als erster Schlüssel in jedem Bedingungsobjekt, sonst können Deklarationsdateien übersehen werden.
Subpath-Exports explizit halten
Statt Wildcard-Mustern gezielt einzelne Unterpfade definieren, um öffentliche API klar abzugrenzen.
Dual-Package-Hazard vermeiden
Zustandsbehafteten Code intern nur über ein Modulsystem laden, um doppelte Instanzen zu verhindern.
Debugging-Werkzeuge
require.resolve, dynamischer import() und NODE_DEBUG=module zeigen die tatsächliche Auflösung.