Tree Shaking bei TypeScript-Bibliotheken: Bundles wirklich schlank halten
AI generated
<T>
type
TypeScript · Tree Shaking · Bundle-Größe · ESM
Tree Shaking bei TypeScript-Bibliotheken
warum ungenutzter Code trotzdem im Bundle landet

Ein einzelner Import aus einer schlecht strukturierten TypeScript-Bibliothek kann trotz moderner Bundler mehrere hundert Kilobyte ungenutzten Code ins Konsumenten-Bundle ziehen. Tree Shaking ist keine automatische Bundler-Magie, sondern hängt entscheidend davon ab, wie die Bibliothek selbst gebaut, exportiert und im sideEffects-Feld deklariert wurde.

17 Min. Lesezeit sideEffects · ESM · Barrel Files · Rollup · Webpack TypeScript 5.x · Rollup 4.x

1. Warum Tree Shaking keine automatische Garantie ist

Tree Shaking beschreibt die Fähigkeit eines Bundlers, aus einem importierten Modul nur den tatsächlich verwendeten Code in das finale Bundle zu übernehmen und alles andere zu entfernen. Viele Entwickler gehen davon aus, dass ein moderner Bundler wie Rollup, Webpack oder esbuild diese Aufgabe bei jeder TypeScript-Bibliothek automatisch und vollständig löst, sobald nur ein einzelner Named Import verwendet wird. In der Praxis ist das Gegenteil oft der Fall: Zahlreiche populäre npm-Pakete ziehen bei einem einzigen Import den kompletten Modulinhalt ins Bundle, weil bestimmte strukturelle Voraussetzungen fehlen.

Der Grund liegt darin, dass Tree Shaking auf statischer Codeanalyse beruht, die bestimmte Muster im Quellcode zwingend voraussetzt. Ein Bundler kann nur dann sicher entscheiden, dass ein Codepfad ungenutzt und damit entfernbar ist, wenn er beweisen kann, dass dessen Ausführung keine beobachtbaren Seiteneffekte hat. Genau diese Beweisbarkeit geht bei bestimmten, in TypeScript-Bibliotheken erstaunlich häufigen Mustern verloren, etwa bei zentralen Re-Export-Dateien oder bei Modulen, die beim Import automatisch Code ausführen. Das Ergebnis sind Bundles, die trotz minimaler Nutzung unnötig groß bleiben.

2. Wie Bundler tote Codepfade technisch erkennen

Moderne Bundler nutzen die statische Struktur von ESM-Imports, um vor der eigentlichen Ausführung einen vollständigen Abhängigkeitsgraphen aufzubauen. Da import/export-Deklarationen anders als require()-Aufrufe zur Analysezeit fest und nicht dynamisch veränderbar sind, kann der Bundler exakt bestimmen, welche Exporte eines Moduls von welcher Datei überhaupt referenziert werden. Wird ein exportierter Name nirgends importiert, markiert der Bundler den zugehörigen Code als toten Codepfad und entfernt ihn im finalen Build.

Diese Analyse funktioniert aber nur zuverlässig, wenn der Bundler zusätzlich beweisen kann, dass der Code beim reinen Laden des Moduls, unabhängig von der tatsächlichen Nutzung, keine Seiteneffekte auslöst. Ein Modul, das beim Import sofort eine globale Registry befüllt, einen HTTP-Request auslöst oder ein DOM-Element modifiziert, darf aus Korrektheitsgründen nicht entfernt werden, selbst wenn keiner seiner Exporte importiert wird, denn das Entfernen würde beobachtbares Verhalten der Anwendung verändern. Diese Vorsichtsregel ist der Hauptgrund, warum Tree Shaking bei realen TypeScript-Bibliotheken oft weniger effektiv ist, als die reine Bundler-Dokumentation vermuten lässt.

3. Das sideEffects-Feld in package.json korrekt setzen

Das sideEffects-Feld in package.json löst genau dieses Dilemma, indem der Bibliotheksautor explizit erklärt, welche Dateien Seiteneffekte beim Laden haben und welche garantiert frei davon sind. "sideEffects": false teilt dem Bundler mit, dass ausnahmslos jede Datei im Paket ohne Bedenken entfernt werden darf, wenn keiner ihrer Exporte genutzt wird. Diese globale Aussage ist die stärkste und effektivste Option für reine Utility-Bibliotheken ohne Initialisierungscode, verlangt aber vom Autor Sorgfalt, denn eine fälschlich gesetzte false-Angabe kann zu subtilen Laufzeitfehlern bei den Konsumenten führen, wenn tatsächlich ein Modul mit Seiteneffekten existiert.

Für Bibliotheken mit einzelnen Dateien, die bewusste Seiteneffekte haben, etwa das Registrieren eines Polyfills oder CSS-Importe, erlaubt sideEffects zusätzlich eine Array-Notation mit expliziten Pfad-Mustern, die von der allgemeinen Entfernbarkeit ausgenommen werden. Diese granulare Deklaration ist der übliche Mittelweg für größere Bibliotheken, bei denen einzelne Module wie ein globaler Error-Handler oder ein CSS-Reset niemals entfernt werden dürfen, während der Großteil des Codes sicher tree-shakebar bleibt.


{
  "name": "@mironsoft/ui-kit",
  "version": "4.1.0",
  "sideEffects": [
    "*.css",
    "./src/polyfills/intl-fallback.ts",
    "./src/register-global-error-handler.ts"
  ]
}

4. ESM-Output als Voraussetzung für statische Analyse

Damit ein Bundler die zuvor beschriebene statische Analyse überhaupt durchführen kann, muss die kompilierte TypeScript-Bibliothek im ESM-Format ausgeliefert werden, nicht im CommonJS-Format. require()-Aufrufe können theoretisch dynamisch mit berechneten Pfaden aufgerufen werden, was es einem Bundler unmöglich macht, den vollständigen Abhängigkeitsgraphen sicher statisch zu bestimmen. Selbst wenn eine CommonJS-Datei tatsächlich nur statische require()-Aufrufe enthält, müssen Bundler konservativ vorgehen und können nicht dasselbe Maß an Sicherheit erreichen wie bei nativer ESM-Syntax.

In der tsconfig.json bedeutet das konkret, dass für tree-shakebare Bibliotheken "module": "ESNext" oder "module": "ES2022" für den öffentlich ausgelieferten Build gesetzt werden muss, während ein separater CommonJS-Build für Legacy-Konsumenten zwar weiterhin sinnvoll sein kann, aber niemals als einziges Format existieren sollte. Das module-Feld in der package.json, nicht main, ist dabei der Pfad, den Bundler wie Webpack und Rollup bevorzugt für den ESM-Build auswerten, weshalb dieses Feld bei tree-shaking-relevanten Paketen zwingend gepflegt werden muss.


// src/index.ts — barrel file with named, statically analyzable exports
export { formatCurrency } from './format-currency';
export { parseDate } from './parse-date';
export { debounce } from './debounce';
export { throttle } from './throttle';

// Consumer only importing formatCurrency:
// import { formatCurrency } from '@mironsoft/ui-kit';
//
// With ESM output + sideEffects: false, a bundler can prove
// that parseDate, debounce and throttle are unreachable and
// safely exclude them from the final bundle.

5. Barrel Files: der heimliche Tree-Shaking-Killer

Ein Barrel File, also eine zentrale index.ts, die alle öffentlichen Module einer Bibliothek re-exportiert, ist praktisch für Konsumenten, aber unter bestimmten Umständen ein erhebliches Hindernis für Tree Shaking. Enthält eine der re-exportierten Dateien auch nur einen einzigen unklaren Seiteneffekt, etwa eine Modul-Level-Funktion, deren Aufruf der Bundler nicht sicher als seiteneffektfrei beweisen kann, verhindert das konservative Vorgehen des Bundlers das Entfernen des gesamten Barrel-Inhalts, selbst wenn sideEffects: false gesetzt ist, sofern der Bundler dem nicht vollständig vertraut.

Ein zusätzliches, praktisches Problem großer Barrel Files ist, dass IDE-Autovervollständigung und statische Analyse-Tools bei tiefen Re-Export-Ketten langsamer werden, weil TypeScript für jede Typinformation durch mehrere Indirektionsebenen navigieren muss. Große Bibliotheken wie lodash oder date-fns haben deshalb bewusst auf ein Modell mit vielen kleinen, unabhängigen Einstiegspunkten umgestellt, sodass Konsumenten direkt import debounce from 'lodash/debounce' statt import { debounce } from 'lodash' schreiben können, was das Tree-Shaking-Problem umgeht, indem der Bundler gar nicht erst den gesamten Barrel-Inhalt analysieren muss.


// package.json exports map with individual entry points instead of one barrel
// {
//   "exports": {
//     "./debounce": "./dist/debounce.js",
//     "./throttle": "./dist/throttle.js",
//     "./format-currency": "./dist/format-currency.js"
//   }
// }

// Consumer imports exactly one function, bundler never touches the rest
import debounce from '@mironsoft/ui-kit/debounce';

6. Reine Funktionen und /*#__PURE__*/-Annotationen

Funktionsaufrufe auf Modulebene, etwa const instance = createDefaultConfig() direkt außerhalb einer Funktion, sind für Bundler besonders schwer als seiteneffektfrei zu beweisen, selbst wenn die aufgerufene Funktion tatsächlich rein ist. Der Bundler müsste den kompletten Funktionskörper analysieren und ausschließen können, dass irgendwo darin ein Zugriff auf globalen Zustand, eine Konsolenausgabe oder eine andere Nebenwirkung steckt, was bei komplexeren Funktionen praktisch unmöglich statisch zu beweisen ist.

Die Kommentar-Annotation /*#__PURE__*/ direkt vor einem solchen Funktionsaufruf ist eine von Terser, Rollup und esbuild unterstützte Konvention, mit der ein Bibliotheksautor dem Bundler explizit mitteilt, dass genau dieser Aufruf frei von Seiteneffekten ist und bei Nichtnutzung des Ergebnisses sicher entfernt werden darf. TypeScript selbst fügt diese Annotation bei bestimmten kompilierten Konstrukten automatisch ein, etwa bei manchen Klassen-Transformationen, aber für eigene modul-level Initialisierungen sollten Bibliotheksautoren die Annotation bewusst und sparsam von Hand setzen, da eine falsch platzierte __PURE__-Annotation tatsächlich benötigten Code entfernen und damit Laufzeitfehler beim Konsumenten verursachen kann.


// Module-level call that a bundler cannot prove is side-effect free on its own
const DEFAULT_CONFIG = /*#__PURE__*/ createDefaultConfig();

// Rollup, esbuild and Terser trust this annotation and remove
// the call entirely if DEFAULT_CONFIG is never actually used
// by the consuming application after tree shaking.

export function createDefaultConfig() {
  return { retries: 3, timeoutMs: 5000 };
}

7. Klassen, Decorators und die Grenzen von Tree Shaking

Klassen stellen für Tree Shaking ein grundsätzliches Problem dar, das sich mit Konventionen allein nicht vollständig lösen lässt: Eine Klassendefinition selbst gilt als potenziell seiteneffektbehaftet, weil statische Klasseneigenschaften, statische Initialisierungsblöcke und Decorators beliebigen Code zur Definitionszeit ausführen können. Selbst wenn keine Instanz der Klasse jemals erzeugt wird, kann der Bundler die Klassendefinition oft nicht sicher entfernen, weil er nicht garantieren kann, dass diese statischen Bestandteile frei von Nebenwirkungen sind.

Für TypeScript-Bibliotheken, bei denen kleine Bundle-Größen Priorität haben, ist eine funktionale API mit reinen Funktionen fast immer besser tree-shakebar als eine klassenbasierte API mit vielen statischen Methoden. Wo Klassen aus Design-Gründen dennoch sinnvoll sind, etwa bei zustandsbehafteten Objekten mit klarer Lebenszyklus-Semantik, hilft es, statische Initialisierung zu vermeiden und Decorators nur dort einzusetzen, wo ihr tatsächlicher Mehrwert die eingeschränkte Tree-Shaking-Fähigkeit rechtfertigt.

8. Bundle-Größe messen und Regressionen erkennen

Ohne kontinuierliche Messung bleibt jede Aussage über die Tree-Shaking-Fähigkeit einer TypeScript-Bibliothek reine Theorie. Tools wie size-limit oder bundlephobia erlauben es, die tatsächliche Bundle-Größe eines minimalen Konsumenten-Imports automatisiert zu messen und in der CI-Pipeline gegen eine definierte Obergrenze zu prüfen. Ein typisches Setup importiert nur eine einzelne Funktion aus der Bibliothek, baut daraus mit dem echten Bundler ein minimales Produktions-Bundle und vergleicht dessen Größe nach Minifizierung und Gzip-Kompression mit dem in der CI hinterlegten Grenzwert.

Diese automatisierte Prüfung deckt Regressionen auf, die sonst unbemerkt blieben, etwa wenn ein neues internes Abhängigkeit versehentlich ohne sideEffects-Deklaration eingeführt wird oder ein Refactoring eine zuvor reine Funktion in eine Funktion mit Modul-Level-Seiteneffekt verwandelt. Für öffentlich verteilte TypeScript-Bibliotheken gehört ein solcher Bundle-Size-Check inzwischen zum Standard-Repertoire jeder seriösen CI-Pipeline, ähnlich wie automatisierte Tests oder Linting.


{
  "size-limit": [
    {
      "name": "formatCurrency (single named import)",
      "path": "dist/esm/index.js",
      "import": "{ formatCurrency }",
      "limit": "2 KB"
    },
    {
      "name": "full library (worst case)",
      "path": "dist/esm/index.js",
      "limit": "18 KB"
    }
  ],
  "scripts": {
    "size": "size-limit"
  }
}

9. Tree-Shaking-Strategien im Vergleich

Verschiedene strukturelle Entscheidungen beim Bibliotheksdesign wirken sich unterschiedlich stark auf die tatsächliche Tree-Shaking-Effektivität aus. Die folgende Übersicht vergleicht die gängigen Ansätze.

Strategie Tree-Shaking-Effektivität Konsumenten-Komfort Empfehlung
CommonJS-Only-Output Sehr gering Hoch Für Bibliotheken vermeiden
ESM-Output ohne sideEffects-Feld Mittel, konservativ Hoch Besser als nichts, aber unvollständig
ESM + sideEffects: false Hoch Hoch Standard für Utility-Bibliotheken
Einzelne Einstiegspunkte statt Barrel Sehr hoch Etwas geringer Beste Wahl bei großen Bibliotheken
Klassenbasierte API mit statischer Init Gering Abhängig vom Design Nur bei klarem Design-Mehrwert

Die größten Verbesserungen entstehen fast immer durch die Kombination aus ESM-Output, korrektem sideEffects-Feld und dem bewussten Vermeiden großer, monolithischer Barrel Files. Für sehr große Bibliotheken mit vielen unabhängigen Funktionsbereichen lohnt sich zusätzlich der Wechsel zu einzelnen Einstiegspunkten, auch wenn das für Konsumenten geringfügig mehr Importzeilen bedeutet.

Mironsoft

TypeScript-Bibliotheksarchitektur und Bundle-Size-Optimierung

Bundles der Konsumenten spürbar verkleinern?

Wir analysieren eure TypeScript-Bibliothek auf Tree-Shaking-Hindernisse, konfigurieren sideEffects und ESM-Output korrekt und richten automatisierte Bundle-Size-Checks in eurer CI ein.

Tree-Shaking-Audit

Analyse eurer Bibliothek auf Barrel-Files, Seiteneffekte und Bundle-Aufblähung

Refactoring

sideEffects-Feld, ESM-Output und Einstiegspunkt-Struktur optimieren

CI-Integration

Automatisierte Bundle-Size-Checks mit size-limit gegen Regressionen

10. Zusammenfassung

Tree Shaking bei TypeScript-Bibliotheken ist keine automatische Eigenschaft moderner Bundler, sondern das Ergebnis bewusster struktureller Entscheidungen des Bibliotheksautors. ESM-Output statt CommonJS ist die technische Grundvoraussetzung, da nur die statische import/export-Syntax dem Bundler eine sichere Analyse erlaubt. Das korrekt gepflegte sideEffects-Feld in package.json entscheidet darüber, wie aggressiv der Bundler ungenutzten Code entfernen darf, ohne beobachtbares Verhalten zu riskieren.

Barrel Files, Modul-Level-Funktionsaufrufe ohne /*#__PURE__*/-Annotation und klassenbasierte APIs mit statischer Initialisierung sind die häufigsten praktischen Hindernisse, die Tree Shaking in der Praxis verhindern, selbst wenn die technischen Grundvoraussetzungen erfüllt sind. Kontinuierliche Bundle-Size-Messung in der CI-Pipeline macht Regressionen sichtbar, bevor sie bei Konsumenten zu unnötig aufgeblähten Anwendungen führen.

Tree Shaking bei TypeScript-Bibliotheken — Das Wichtigste auf einen Blick

ESM statt CommonJS

Nur statische import/export-Syntax erlaubt Bundlern eine sichere Analyse des Abhängigkeitsgraphen.

sideEffects-Feld

Global false für reine Utility-Bibliotheken, granulares Array bei einzelnen Seiteneffekt-Dateien.

Barrel Files vermeiden

Einzelne Einstiegspunkte statt zentraler Re-Export-Datei bei großen Bibliotheken.

Kontinuierlich messen

size-limit oder bundlephobia in CI gegen definierte Obergrenzen prüfen.

11. FAQ: Tree Shaking bei TypeScript-Bibliotheken

1Reicht ESM-Output allein?
Nein, ohne korrektes sideEffects-Feld bleibt der Bundler konservativ.
2Risiko bei falschem sideEffects: false?
Subtile Laufzeitfehler, wenn tatsächlich seiteneffektbehafteter Code entfernt wird.
3Warum sind Barrel Files problematisch?
Ein einziger unklarer Seiteneffekt kann das Entfernen des gesamten Barrel-Inhalts verhindern.
4Wofür __PURE__-Annotation?
Signalisiert Bundlern explizit Seiteneffektfreiheit eines bestimmten Funktionsaufrufs.
5Warum Klassen schlechter tree-shakebar?
Statische Eigenschaften und Decorators können Code zur Definitionszeit ausführen.
6Wie messe ich Tree-Shaking-Erfolg?
Mit size-limit oder bundlephobia gegen eine definierte Bundle-Size-Obergrenze in CI.
7main oder module priorisieren?
Bundler bevorzugen module für ESM, beide Felder sollten synchron gepflegt werden.
8Warum einzelne Einstiegspunkte bei lodash?
Direkte Imports umgehen das Barrel-Problem, da der Bundler es nicht analysieren muss.
9Kann CommonJS tree-shakebar sein?
Nur eingeschränkt, da require()-Aufrufe dynamisch sein können.
10Granulares Array besser als false?
Ja, bei tatsächlichen Seiteneffekt-Dateien, da es präziser steuert als eine globale Angabe.