Enum oder Union String Literal in TypeScript?
1. Übersicht
Wenn Sie einen festen Satz von Werten darstellen müssen – wie den Status einer Bestellung oder eine Zahlungsmethode – bietet TypeScript zwei beliebte Möglichkeiten: enum und Union String Literal ('PENDING' | 'PAID'). Beide sehen im Editor ähnlich aus, unterscheiden sich aber zur Laufzeit, in der Weise, wie der Type Checker damit umgeht, und in der Kompatibilität mit modernem Tooling.
Dieser Artikel analysiert jeden Ansatz anhand konkreter Beispiele, zeigt häufig auftretende Fehler und stellt das as const Pattern vor, das die Vorteile beider Wege kombiniert. Danach haben Sie klare Kriterien, um das Richtige für Ihr Projekt zu wählen.
2. Enum in TypeScript
2.1. String Enum
enum OrderStatus {
Pending = 'PENDING',
Paid = 'PAID',
Shipped = 'SHIPPED',
Cancelled = 'CANCELLED',
}
function canCancel(status: OrderStatus): boolean {
return status === OrderStatus.Pending || status === OrderStatus.Paid;
}
console.log(canCancel(OrderStatus.Paid));
console.log(Object.values(OrderStatus));
Wenn Sie npx tsx order.ts ausführen, erhalten Sie:
true
[ 'PENDING', 'PAID', 'SHIPPED', 'CANCELLED' ]
Das Wichtigste: enum ist nicht nur ein Type. Der Compiler erzeugt zur Laufzeit ein echtes Objekt. Deshalb können Sie Object.values(OrderStatus) aufrufen. Der generierte JavaScript-Code sieht so aus:
var OrderStatus;
(function (OrderStatus) {
OrderStatus["Pending"] = "PENDING";
OrderStatus["Paid"] = "PAID";
OrderStatus["Shipped"] = "SHIPPED";
OrderStatus["Cancelled"] = "CANCELLED";
})(OrderStatus || (OrderStatus = {}));
2.2. Numerisches Enum und Reverse Mapping
Wenn Sie keine Werte zuweisen, werden Enums ab 0 durchnummeriert:
enum Priority {
Low,
Medium,
High,
}
console.log(Priority.High);
console.log(Priority[2]);
console.log(Object.keys(Priority));
2
High
[ '0', '1', '2', 'Low', 'Medium', 'High' ]
Numerisches Enum hat Reverse Mapping: Priority[2] gibt 'High' zurück. Folge davon ist, dass Object.keys sowohl Nummern als auch Namen zurückgibt. Das ist ein klassischer Fehler, wenn Sie eine Dropdown-Liste aus einem Enum rendern und die Liste plötzlich doppelt erscheint.
Hinweis: Numerisches Enum akzeptiert immer noch eine beliebige Variable vom Typ
number. Zum Beispielconst p: Priority = someNumber;kompiliert normal, auch wennsomeNumber99ist. Seit TypeScript 5.0 wird das Zuweisen eines Literals außerhalb des Bereichs wieconst p: Priority = 99;als Fehler erkannt. Einenumber-Variable wird aber immer noch akzeptiert.
2.3. Enums haben “nominale” Typen
String Enum akzeptiert nicht einfach die entsprechenden String-Literale:
canCancel(OrderStatus.Paid); // OK
canCancel('PAID'); // Fehler: Argument of type '"PAID"' is not assignable to parameter of type 'OrderStatus'.
Sie können das als Vorteil sehen: Es zwingt Sie, überall OrderStatus zu verwenden. Aber wenn Daten von einer API-JSON kommen, müssen Sie entweder casten (as OrderStatus) oder eine Konvertierungsfunktion schreiben.
3. Union String Literal
type PaymentMethod = 'CARD' | 'BANK_TRANSFER' | 'COD';
interface Payment {
orderId: string;
method: PaymentMethod;
amount: number;
}
const payment: Payment = { orderId: 'ORD-001', method: 'COD', amount: 250_000 };
console.log(payment.method);
// const wrong: Payment = { orderId: 'ORD-002', method: 'PAYPAL', amount: 1 };
// Fehler: Type '"PAYPAL"' is not assignable to type 'PaymentMethod'.
COD
Union Type wird vollständig gelöscht, wenn es kompiliert wird. Der resultierende JavaScript enthält nur das Objekt payment mit der Zeichenfolge 'COD', keinen zusätzlichen Code. Der Editor schlägt trotzdem alle drei Werte vor, wenn Sie method: ' eingeben.
Schwachpunkt: Da er nicht zur Laufzeit existiert, können Sie die Werte von PaymentMethod nicht durchlaufen. Sie können auch Eingaben nicht validieren, ohne eine separate Liste zu deklarieren.
3.1. Werteliste mit as const abrufen
Die Lösung besteht darin, zunächst das Array zu deklarieren und dann den Type daraus abzuleiten:
export const PAYMENT_METHODS = ['CARD', 'BANK_TRANSFER', 'COD'] as const;
export type PaymentMethod = (typeof PAYMENT_METHODS)[number];
export function isPaymentMethod(value: string): value is PaymentMethod {
return (PAYMENT_METHODS as readonly string[]).includes(value);
}
console.log(PAYMENT_METHODS);
console.log(isPaymentMethod('COD'), isPaymentMethod('PAYPAL'));
[ 'CARD', 'BANK_TRANSFER', 'COD' ]
true false
Erklärung:
as constbehält den Literal-Typ jedes Elements bei. Das Array hat den Typreadonly ['CARD', 'BANK_TRANSFER', 'COD']anstattstring[].(typeof PAYMENT_METHODS)[number]ergibt die Union aller Elemente, also'CARD' | 'BANK_TRANSFER' | 'COD'.isPaymentMethodist ein Type Guard. Nach der Überprüfung verengt TypeScriptvalueaufPaymentMethod.- Das Casten zu
readonly string[]ist notwendig, weilincludesauf einem Literal-Tuple nur die genauen Literale akzeptiert.
Von nun an bleiben Liste und Type immer synchron. Wenn Sie ein Element zum Array hinzufügen, aktualisiert sich der Type automatisch.
4. as const Object Pattern: der mittlere Weg
Viele Teams mögen die OrderStatus.Paid Syntax von Enums, wollen aber nicht ihre Nachteile. Sie können ein as const Object verwenden:
export const OrderStatus = {
Pending: 'PENDING',
Paid: 'PAID',
Shipped: 'SHIPPED',
Cancelled: 'CANCELLED',
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
function canCancel(status: OrderStatus): boolean {
return status === OrderStatus.Pending || status === OrderStatus.Paid;
}
console.log(canCancel(OrderStatus.Paid));
console.log(canCancel('SHIPPED'));
true
false
Hier:
- Der gleiche Name
OrderStatuswird für Value (Object) und Type verwendet. TypeScript erlaubt dies, weil Value und Type in verschiedenen Namespaces liegen. keyof typeof OrderStatusist'Pending' | 'Paid' | .... Der Indexed Access gibt die Union der Werte'PENDING' | 'PAID' | ...zurück.- Sie können immer noch
OrderStatus.Paidschreiben und gleichzeitig'SHIPPED'direkt aus JSON-Daten übergeben, ohne zu casten. - Der generierte Code ist nur ein normales Object Literal, ohne IIFE, ohne Reverse Mapping.
5. Exhaustive Checking mit never
Sowohl Enum als auch Union unterstützen die Überprüfung, ob alle Fälle behandelt wurden. Das ist der Hauptgrund, einen festen Satz von Werten statt string zu verwenden.
import { OrderStatus } from './order-status';
export function statusLabel(status: OrderStatus): string {
switch (status) {
case 'PENDING':
return 'Ausstehende Zahlung';
case 'PAID':
return 'Bezahlt';
case 'SHIPPED':
return 'Versand läuft';
case 'CANCELLED':
return 'Storniert';
default: {
const unreachable: never = status;
throw new Error(`Ungültiger Status: ${unreachable}`);
}
}
}
Wenn Sie später Refunded: 'REFUNDED' zu OrderStatus hinzufügen, aber den case vergessen, wird die Zeile const unreachable: never = status; einen Fehler auslösen:
Type 'string' is not assignable to type 'never'.
Genauer gesagt, wird die Meldung lauten, dass '"REFUNDED"' is not assignable to type 'never'. Der Fehler tritt zum Zeitpunkt der Kompilierung auf, bevor der Code in die Produktion geht. Der throw-Branch ist immer noch hilfreich zur Laufzeit, wenn unerwartete Daten von außen eindringen.
6. Tests für das Kernverhalten schreiben
Vitest installieren:
npm install -D vitest
Tests für den Type Guard und die Funktion statusLabel:
import { describe, expect, it } from 'vitest';
import { isPaymentMethod, PAYMENT_METHODS } from './payment-method';
import { statusLabel } from './status-label';
import { OrderStatus } from './order-status';
describe('PaymentMethod', () => {
it('akzeptiert alle Werte in der Liste', () => {
for (const method of PAYMENT_METHODS) {
expect(isPaymentMethod(method)).toBe(true);
}
});
it('lehnt unbekannte Werte ab', () => {
expect(isPaymentMethod('PAYPAL')).toBe(false);
expect(isPaymentMethod('cod')).toBe(false);
});
});
describe('statusLabel', () => {
it('gibt Labels für alle Status zurück', () => {
const labels = Object.values(OrderStatus).map(statusLabel);
expect(labels).toEqual(['Ausstehende Zahlung', 'Bezahlt', 'Versand läuft', 'Storniert']);
});
it('wirft Fehler bei ungültigen Runtime-Daten', () => {
expect(() => statusLabel('REFUNDED' as OrderStatus)).toThrow('Ungültiger Status: REFUNDED');
});
});
Führen Sie npx vitest run aus:
✓ PaymentMethod > akzeptiert alle Werte in der Liste
✓ PaymentMethod > lehnt unbekannte Werte ab
✓ statusLabel > gibt Labels für alle Status zurück
✓ statusLabel > wirft Fehler bei ungültigen Runtime-Daten
Test Files 1 passed (1)
Tests 4 passed (4)
Der zweite Test zeigt, dass der Type Guard zwischen Groß- und Kleinbuchstaben unterscheidet. Das ist ein leicht zu übersehendes Detail beim Parsing von Formulareingaben.
7. Modernes Tooling und const enum
Immer mehr Tools entfernen nur Types, statt TypeScript vollständig zu kompilieren. Zum Beispiel: Node.js Type-Stripping-Modus oder esbuild und SWC bei separater Dateiverarbeitung. Diese Tools können mit Syntax nicht umgehen, die Runtime-Code generiert, wie enum.
TypeScript 5.8 hat ein Flag erasableSyntaxOnly, um solche Syntaxen zu verbieten:
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"isolatedModules": true
}
}
Wenn Sie das Flag aktivieren, wird jede enum Deklaration einen Fehler auslösen. Union Literals und as const Objects bleiben gültig, da sie reines JavaScript mit Type Annotations sind.
const enum wurde früher als “kostenloses” Enum beworben, weil der Compiler Werte inline setzt, statt ein Objekt zu erzeugen. Es verursacht aber viele Probleme:
- Kann nicht sicher mit
isolatedModulesverwendet werden, da der File-by-File Compiler die Enum-Werte aus anderen Dateien nicht kennt. - Beim Veröffentlichen von Bibliotheken kann
const enumin.d.tsDateien den Code von Benutzern beschädigen, wenn diese nichttscverwenden.
Hinweis: Vermeiden Sie
const enumin neuem Code, besonders in Bibliotheken. Wenn Sie Runtime-Overhead vermeiden möchten, können Union Literals das schon ohne Risiken tun.
8. Wann verwendet man welche Methode?
| Kriterium | enum |
Union Literal | Object as const |
|---|---|---|---|
| Hat Wert zur Laufzeit | Ja | Nein (benötigt separates Array) | Ja |
| Akzeptiert rohe Strings aus JSON | Casting erforderlich | Ja | Ja |
| Type-Stripping kompatibel | Nein | Ja | Ja |
X.Value Syntax |
Ja | Nein | Ja |
| Exhaustive Check | Ja | Ja | Ja |
Praktische Empfehlungen:
- Union Literal: Standard für die meisten Fälle, besonders für Props, DTOs und Funktionsparameter. Am kompaktesten, kein Runtime-Code.
- Array
as const+ Union: wenn Sie Werte durchlaufen müssen (Dropdowns, Input-Validierung) oder mit Validierungsbibliotheken arbeiten. - Object
as const: wenn das Team die Enum-ähnliche Syntax bevorzugt oder Display-Namen anders als die tatsächlichen Werte sind (Pendingvs'PENDING'). enum: wenn die Codebase konsistent damit arbeitet und vollständig mittscgebaut wird, oder wenn Sie wirklich nominale Typen mögen (keine rohen Strings erlaubt).
Häufig auftretende Fehler:
Object.keysbei numerischen Enums verwenden und eine doppelte Liste erhalten aufgrund von Reverse Mapping.- Numerisches Enum beliebige
numberakzeptieren lassen, dann werden Daten in der DB fehlerhaft, wenn jemand ein Member in der Mitte hinzufügt. Die Reihenfolge ändert sich und damit auch die Werte. as OrderStatuscasten, sobald Daten von einer API ankommen, statt sie mit Type Guards zu überprüfen. Der Type Checker wird Ihnen glauben, aber falsche Daten gehen weiter.as constvergessen, wodurch der Type sich zustringerweitert und die ganze Wirkung verloren geht.
9. Fazit
Enum und Union String Literal lösen beide das Problem „fester Satz von Werten”. Der Unterschied ist, dass Enums Runtime-Code erzeugen und nominale Typen sind. Union Literals existieren nur auf der Type-Ebene, sind daher leichter und moderner-Tooling-freundlich.
Bei neuer TypeScript-Code sollten Sie mit Union Literals beginnen. Wenn Sie zur Laufzeit eine Liste brauchen, kombinieren Sie sie mit einem Array oder as const Object. Egal wofür Sie sich entscheiden, verwenden Sie Exhaustive Checks mit never und Type Guards an Systemgrenzen. Das ist es, was Code wirklich sicher macht.