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 Beispiel const p: Priority = someNumber; kompiliert normal, auch wenn someNumber 99 ist. Seit TypeScript 5.0 wird das Zuweisen eines Literals außerhalb des Bereichs wie const p: Priority = 99; als Fehler erkannt. Eine number-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 const behält den Literal-Typ jedes Elements bei. Das Array hat den Typ readonly ['CARD', 'BANK_TRANSFER', 'COD'] anstatt string[].
  • (typeof PAYMENT_METHODS)[number] ergibt die Union aller Elemente, also 'CARD' | 'BANK_TRANSFER' | 'COD'.
  • isPaymentMethod ist ein Type Guard. Nach der Überprüfung verengt TypeScript value auf PaymentMethod.
  • Das Casten zu readonly string[] ist notwendig, weil includes auf 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 OrderStatus wird für Value (Object) und Type verwendet. TypeScript erlaubt dies, weil Value und Type in verschiedenen Namespaces liegen.
  • keyof typeof OrderStatus ist 'Pending' | 'Paid' | .... Der Indexed Access gibt die Union der Werte 'PENDING' | 'PAID' | ... zurück.
  • Sie können immer noch OrderStatus.Paid schreiben 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 isolatedModules verwendet werden, da der File-by-File Compiler die Enum-Werte aus anderen Dateien nicht kennt.
  • Beim Veröffentlichen von Bibliotheken kann const enum in .d.ts Dateien den Code von Benutzern beschädigen, wenn diese nicht tsc verwenden.

Hinweis: Vermeiden Sie const enum in 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 (Pending vs 'PENDING').
  • enum: wenn die Codebase konsistent damit arbeitet und vollständig mit tsc gebaut wird, oder wenn Sie wirklich nominale Typen mögen (keine rohen Strings erlaubt).

Häufig auftretende Fehler:

  • Object.keys bei numerischen Enums verwenden und eine doppelte Liste erhalten aufgrund von Reverse Mapping.
  • Numerisches Enum beliebige number akzeptieren 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 OrderStatus casten, 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 const vergessen, wodurch der Type sich zu string erweitert 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.

typescriptenumunion typetype safetybest practices