Zwei Wege zu einer festen Menge erlaubter Werte, mit unterschiedlichen Kompromissen
TypeScript bietet mit Enums und Union-Literal-Typen zwei grundverschiedene Werkzeuge, um eine feste Menge erlaubter Werte abzubilden. Beide haben ihre Berechtigung, doch die Wahl hat spürbare Auswirkungen auf Bundle-Größe, Serialisierung und Interoperabilität.
Inhaltsverzeichnis
- 1. Zwei Ansätze für eine feste Wertemenge
- 2. Numerische Enums und ihre Eigenheiten
- 3. String-Enums und const enum
- 4. Union-Literal-Typen als Alternative
- 5. Strukturelle vs. nominale Eigenschaften
- 6. JSON-Serialisierung und API-Grenzen
- 7. Wann Enums tatsächlich sinnvoll sind
- 8. Praktische Empfehlung
- 9. Fallstricke bei der Migration
- 10. Zusammenfassung
- 11. FAQ
1. Zwei Ansätze für eine feste Wertemenge
Sowohl Enums als auch Union-Literal-Typen lösen dasselbe Grundproblem: eine Variable soll nur einen von mehreren vordefinierten Werten annehmen dürfen. Wie dieses Ziel erreicht wird, unterscheidet sich jedoch fundamental. Enums erzeugen eine echte Laufzeit-Konstruktion, Union-Literal-Typen existieren dagegen ausschließlich im Typsystem.
Diese unterschiedliche Natur zieht Konsequenzen nach sich, die weit über reine Geschmacksfragen hinausgehen: Bundle-Größe, Verhalten bei der JSON-Serialisierung, Debugging-Erfahrung und Interoperabilität mit reinem JavaScript-Code sind alle betroffen.
Gerade in Teams, die von anderen Sprachen mit klassischen Enums zu TypeScript wechseln, ist die Versuchung groß, reflexartig zu Enums zu greifen. Ein bewusster Vergleich beider Ansätze zahlt sich jedoch aus, bevor sich eine Konvention im gesamten Projekt verfestigt.
2. Numerische Enums und ihre Eigenheiten
Ein numerisches Enum erzeugt zur Laufzeit ein Objekt mit einer sogenannten Reverse-Mapping-Eigenschaft: Nicht nur der Name zeigt auf den Wert, auch der Wert zeigt zurück auf den Namen. Das ist praktisch für Debugging-Ausgaben, führt aber zu doppelt so vielen Einträgen im erzeugten JavaScript-Objekt.
Ein weiteres Risiko numerischer Enums ist ihre implizite Kompatibilität mit jeder beliebigen Zahl. TypeScript erlaubt es, eine beliebige number-Variable an eine Stelle zu übergeben, die ein numerisches Enum erwartet, ohne dass der Compiler dies beanstandet.
Hinzu kommt, dass sich die automatisch vergebenen numerischen Werte bei nachträglichem Einfügen eines neuen Members verschieben können, was bereits gespeicherte Werte in einer Datenbank oder in einer JSON-Datei stillschweigend falsch interpretierbar macht, wenn nicht jeder Member explizit einen festen Wert erhält.
enum Status {
Draft,
Published,
Archived,
}
console.log(Status.Published); // 1
console.log(Status[1]); // "Published" (Reverse Mapping)
function setStatus(s: Status) {}
setStatus(42); // wird vom Compiler nicht beanstandet
3. String-Enums und const enum
String-Enums vermeiden das Reverse-Mapping-Problem und die implizite Kompatibilität mit beliebigen Zahlen, da jeder Member einen expliziten String-Wert erhält. Sie sind damit deutlich sicherer als numerische Enums, erzeugen aber weiterhin ein echtes Laufzeit-Objekt.
Ein const enum wird dagegen zur Kompilierzeit komplett aufgelöst und hinterlässt keinerlei Laufzeit-Objekt: Jede Verwendung wird direkt durch den Literal-Wert ersetzt. Das spart Bundle-Größe, funktioniert aber nicht mit der isolatedModules Compiler-Option, die von Tools wie esbuild, swc oder Babel benötigt wird, da diese Dateien isoliert transpilieren, ohne den Enum-Wert kennen zu können.
enum ExportFormat {
Json = "json",
Xml = "xml",
Csv = "csv",
}
const enum LogLevel {
Info = "info",
Warn = "warn",
Error = "error",
}
// const enum wird bei der Kompilierung durch "info" ersetzt
console.log(LogLevel.Info);
4. Union-Literal-Typen als Alternative
Ein Union-Literal-Typ definiert die erlaubte Wertemenge rein im Typsystem, ohne jegliche Laufzeit-Repräsentation. Zur Laufzeit sind die Werte einfache Strings oder Zahlen, was die Interoperabilität mit reinem JavaScript-Code, JSON-APIs und Bibliotheken ohne eigene Enum-Unterstützung erheblich vereinfacht.
Da kein zusätzliches Objekt erzeugt wird, entfällt auch jeglicher Bundle-Size-Overhead. Für Discriminated Unions, ein zentrales Muster in typsicherem TypeScript-Code, sind Union-Literal-Typen ohnehin die natürliche Wahl.
type ExportFormat = "json" | "xml" | "csv";
function exportData(format: ExportFormat) {
switch (format) {
case "json":
return toJson();
case "xml":
return toXml();
case "csv":
return toCsv();
}
}
5. Strukturelle vs. nominale Eigenschaften
Ein oft übersehener Unterschied betrifft die Typprüfung selbst. Enums verhalten sich in TypeScript teilweise nominal: Zwei unterschiedliche Enums mit identischen Werten sind nicht gegenseitig zuweisbar, selbst wenn die zugrunde liegenden Werte übereinstimmen. Union-Literal-Typen dagegen sind rein strukturell, ein String-Literal ist immer kompatibel, egal aus welchem Kontext es stammt.
Dieses nominale Verhalten kann in großen Codebasen tatsächlich als Feature dienen, um versehentliche Verwechslungen ähnlicher Wertemengen zu verhindern, erschwert aber gleichzeitig die Interoperabilität mit externen Daten.
Wer beide Vorteile verbinden möchte, greift häufig auf Branded Types zurück, eine Technik, die auf Union-Literal-Typen aufbaut, aber zusätzlich ein künstliches Unterscheidungsmerkmal einführt, um versehentliche Verwechslungen ähnlicher String-Typen zu verhindern, ohne dabei die Nachteile echter Enums in Kauf zu nehmen.
6. JSON-Serialisierung und API-Grenzen
An API-Grenzen, etwa beim Empfang von JSON-Daten aus einem Backend, kommen niemals echte Enum-Instanzen an, sondern immer einfache Strings oder Zahlen. Bei Union-Literal-Typen entspricht das exakt der Laufzeit-Repräsentation, eine Konvertierung ist unnötig.
Bei Enums muss der empfangene Rohwert dagegen explizit gegen die erlaubten Enum-Werte geprüft oder in den Enum-Typ umgewandelt werden, was zusätzlichen Code an jeder Systemgrenze erfordert.
Diese zusätzliche Konvertierungslogik summiert sich in größeren Anwendungen mit vielen API-Endpunkten schnell zu spürbarem Mehraufwand, der bei konsequenter Verwendung von Union-Literal-Typen von Anfang an entfällt.
type Status = "draft" | "published" | "archived";
async function fetchStatus(): Promise<Status> {
const res = await fetch("/api/status");
const data = await res.json();
return data.status as Status; // direkt kompatibel
}
7. Wann Enums tatsächlich sinnvoll sind
Trotz der genannten Nachteile haben Enums weiterhin legitime Anwendungsfälle. Bei Bitflag-Mustern, bei denen mehrere Werte über bitweise Operationen kombiniert werden, sind numerische Enums nach wie vor praktisch, da Union-Literal-Typen keine native Unterstützung für solche Kombinationen bieten.
Auch wenn eine echte Laufzeit-Iteration über alle möglichen Werte benötigt wird, etwa um ein Dropdown-Menü dynamisch zu befüllen, bietet ein Enum-Objekt diese Möglichkeit direkt, während bei einem Union-Literal-Typ eine separate Konstante mit allen Werten gepflegt werden muss.
Auch in generiertem Code, etwa aus Protokoll-Definitionen wie Protocol Buffers oder GraphQL-Schemas, tauchen häufig Enums als natives Ausgabeformat auf, sodass sich eine Umstellung auf Union-Literal-Typen in solchen Fällen nicht lohnt, wenn der generierte Code ohnehin nicht manuell gepflegt wird.
8. Praktische Empfehlung
Für die meisten Anwendungsfälle in modernem TypeScript-Code, insbesondere bei Discriminated Unions, API-Antworttypen und Konfigurationswerten, sind Union-Literal-Typen die pragmatischere Wahl: kein Laufzeit-Overhead, einfache Serialisierung, volle Kompatibilität mit reinem JavaScript.
Ist eine echte Laufzeit-Iteration über alle Werte nötig, bietet sich ein Muster mit einem as const Array kombiniert mit einem daraus abgeleiteten Union-Typ an, das die Vorteile beider Welten vereint, ohne ein echtes Enum zu benötigen.
const STATUSES = ["draft", "published", "archived"] as const;
type Status = (typeof STATUSES)[number];
// STATUSES.map(...) ermöglicht Laufzeit-Iteration
// Status bleibt ein reiner Union-Literal-Typ
9. Fallstricke bei der Migration
Beim Ersetzen bestehender Enums durch Union-Literal-Typen sollte man beachten, dass const enum Werte in Downstream-Code oft direkt als Zahlen oder Strings verglichen werden. Eine Migration erfordert daher eine sorgfältige Suche nach allen Verwendungsstellen, insbesondere bei numerischen Enums, deren konkrete Werte oft implizit vorausgesetzt werden.
Ein weiterer Stolperstein: const enum funktioniert nicht in Projekten mit isolatedModules, was seit TypeScript 5.0 sogar für viele Standard-Konfigurationen der Default ist. Wer const enum weiterhin nutzen möchte, muss diese Option explizit deaktivieren, was wiederum andere Build-Tools einschränken kann.
Auch bei Tests lohnt sich ein zweiter Blick: Snapshot-Tests, die auf dem Reverse-Mapping numerischer Enums basieren, brechen häufig unbemerkt, wenn die Reihenfolge der Enum-Member verändert wird. Union-Literal-Typen sind von diesem Risiko naturgemäß nicht betroffen, da sie keine automatisch generierten numerischen Werte besitzen.
Für eine sichere Migration empfiehlt sich zusätzlich eine automatisierte Suche nach allen Importstellen des betroffenen Enums, kombiniert mit einer Übergangsphase, in der beide Typdefinitionen parallel existieren, bis die Umstellung vollständig abgeschlossen ist.
| Merkmal | Numerisches Enum | String-Enum | const enum | Union-Literal-Typ |
|---|---|---|---|---|
| Laufzeit-Objekt | Ja, mit Reverse Mapping | Ja | Nein, wird inline ersetzt | Nein |
| Bundle-Overhead | Ja | Ja | Keiner | Keiner |
| Kompatibel mit isolatedModules | Ja | Ja | Nein | Ja |
| JSON-Serialisierung | Erfordert Konvertierung | Erfordert Konvertierung | Direkt kompatibel | Direkt kompatibel |
| Typprüfung | Teilweise nominal | Teilweise nominal | Teilweise nominal | Rein strukturell |
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
Enums vs. Union-Literale
Laufzeit-Overhead Enum
Echtes Objekt pro Enum
Laufzeit-Overhead Union
Keiner, reines Typsystem
const enum Einschränkung
Inkompatibel mit isolatedModules
Empfehlung
Union-Literale als Standardwahl