Im letzten Quartal haben wir einen Bug ausgeliefert, der den falschen Kunden erstattet hat. Eine processRefund-Funktion erhielt eine User-ID an einer Stelle, die eine Order-ID erwartete. Die Strings sahen identisch aus, die Tests liefen durch und TypeScript hatte nichts dagegen einzuwenden.

Beide Identifier waren als string typisiert. TypeScript sieht keinen Unterschied zwischen ihnen. Wenn du schon einmal eine userId an einen Parameter übergeben hast, der eine orderId erwartet, und dabei zugesehen hast, wie der Compiler achselzuckt, bist du gegen dieselbe Wand gelaufen.

Type-Aliase beheben das nicht. Auch sorgfältige Variablenbenennung hilft nicht. Der Compiler macht genau das, was du ihm gesagt hast – und das ist das Problem.

Warum type UserId = string eine Lüge ist

TypeScript verwendet strukturelle Typisierung. Zwei Typen sind kompatibel, wenn ihre Strukturen übereinstimmen. Wenn du type UserId = string schreibst, hast du keinen neuen Typ erzeugt. Du hast einen Alias erstellt. Zur Compile-Zeit sind UserId und string identisch. Ebenso UserId und OrderId.

type UserId = string;
type OrderId = string;

function fetchOrder(id: OrderId) {
  // ...
}

const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.

Der Compiler entfernt Type-Aliase während der Typprüfung. Der Name UserId ist für Menschen gedacht. Der Type-Checker ignoriert ihn. Das ist normalerweise ein Feature. Es erlaubt dir, Implementationsdetails ohne Zeremonie auszutauschen. Aber für Identifier, die niemals austauschbar sein sollten, ist es eine Falle.

Branded Types machen identische Strukturen inkompatibel

Die Lösung ist ein Branded Type, manchmal auch Opaque Type oder Newtype genannt. Du schneidest den zugrundeliegenden Typ mit einer eindeutigen Marke, die nur auf Typ-Ebene existiert.

Zur Laufzeit ist der Wert immer noch nur ein String. Zur Compile-Zeit macht die Marke ihn von jedem anderen String unterscheidbar.

Hier ist das Muster mit einem Unique Symbol:

declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };

declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };

Das unique symbol garantiert, dass kein anderer Typ diese Marke versehentlich teilen kann. Die readonly-Eigenschaft bedeutet, dass du sie nicht wegmutieren kannst. Zur Laufzeit existiert die Symbol-Eigenschaft nicht auf dem tatsächlichen String, also ist das rein ein Konstrukt zur Compile-Zeit.

Um einen Wert zu erzeugen, brauchst du eine Konstruktor-Funktion, die den Roh-String castet:

function UserId(value: string): UserId {
  return value as UserId;
}

function OrderId(value: string): OrderId {
  return value as OrderId;
}

Jetzt wird der obige Bug bereits erkannt, bevor du den Code überhaupt ausführst:

const uid = UserId("usr_0192");
fetchOrder(uid);
//     ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.

Die Fehlermeldung ist eindeutig. Die Typen sind strukturell verschieden, weil ihre Marken unterschiedlich sind. Der Compiler lässt dich nicht mehr durcheinanderbringen.

Der Boilerplate ist lästig. Hier ist ein Helfer.

Für jeden ID-Typ eine Marke und einen Konstruktor zu schreiben, wird schnell alt. Wir nutzen ein kleines Utility, das beides generiert:

interface Brand<T> {
  readonly __brand: T;
}

type Branded<T, B> = T & Brand<B>;

function makeBrand<T, B>(
  _brand: B
): (value: T) => Branded<T, B> {
  return (value) => value as Branded<T, B>;
}

Verwendung:

type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");

type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");

Das hält die Deklaration auf zwei Zeilen pro Identifier. Du kannst auch den Unique-Symbol-Ansatz in einem generischen Helfer verwenden, wenn du die stärkere Kollisionsresistenz bevorzugst. So oder so ist das Ziel dasselbe: die Kosten für das Hinzufügen eines neuen Branded Types so niedrig halten, dass du es tatsächlich tust.

Was ist mit Objekten und Zahlen?

Branded Types funktionieren für jeden Primitive, nicht nur Strings. Wir branden numerische IDs auf dieselbe Weise:

type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");

Du kannst auch Objekte branden. Wenn du zwei Konfigurationen hast, die dieselbe Form teilen, aber niemals vertauscht werden sollten, brande sie:

type ApiConfig = Branded<{
  endpoint: string;
  timeout: number;
}, "ApiConfig">;

Sei aber vorsichtig mit dem Branden von Objekten. Das Laufzeit-Objekt wird die Brand-Eigenschaft nicht haben, also verhalten sich JSON.stringify und Spread-Operationen normal. Das ist normalerweise genau das, was du willst. Aber wenn du Deep-Equality-Checks machst oder Werte in Bibliotheken übergibst, die Typen zur Laufzeit inspizieren, wird die Marke nicht da sein, um dir zu helfen. Sie ist streng genommen ein Compile-Zeit-Schutz.

Die Kompromisse sind real, aber gering

Branded Types erhöhen den Reibungswiderstand. Jede ID braucht jetzt einen Konstruktor-Aufruf. Du kannst keinen Roh-String mehr inline in eine Funktion übergeben, die einen Branded Type erwartet. Das ist der Sinn der Sache, aber es bedeutet mehr Code.

Serialisierung ist ein weiterer Stolperstein. Wenn du einen branded String mit JSON.stringify serialisierst, bekommst du den Roh-String zurück. Wenn du ihn später parst, hast du die Marke verloren. Du musst den Konstruktor an Systemgrenzen erneut anwenden. Wir machen das normalerweise in API-Response-Parsen und Database-Row-Mappers.

const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));

Branded Types helfen auch nicht bei der Laufzeitvalidierung. Wenn ein String fehlerhaft ist, wird die Marke das nicht abfangen. Du brauchst immer noch zod, valibot oder manuelle Validierung an den Grenzen deines Systems. Die Marke garantiert Typ-Unterschiedlichkeit, nicht Datenkorrektheit.

Wann sich das lohnt, und wann es nur Rauschen ist

Wir branden Identifier, die Modulgrenzen überschreiten: User-IDs, Organization-IDs, Trace-IDs, Span-IDs. Das sind die Werte, die durch den meisten Code wandern und die höchste Wahrscheinlichkeit haben, in der falschen Reihenfolge übergeben zu werden.

Wir branden keine internen Schleifenzähler, lokalen temporären Variablen oder irgendetwas mit einem kleineren Scope als eine Funktion. Der Overhead lohnt sich nicht für Werte, die ihren Geburtsort nie verlassen.

Wenn deine Codebase eine Funktionssignatur mit drei aufeinanderfolgenden String-Parametern hat, ist das ein starkes Signal. Branded Types verwandeln diesen Aufrufort von einem Ratespiel in etwas, das der Compiler für dich prüft.

Ein praktischer Startpunkt

Du musst nicht morgen jeden String in deiner Codebase branden. Wähle die Identifier, die echte Bugs verursacht haben. Füge Marken für diese hinzu. Beobachte, wie der Compiler beim nächsten Refactor ein Durcheinander abfängt. Dieser eine Moment, in dem ein Build-Fehler einen Produktions-Bug verhindert, ist der Punkt, an dem die zusätzlichen Konstruktor-Aufrufe billig erscheinen.

Beginne mit einer Modulgrenze. Füge eine UserId-Marke hinzu. Füge eine OrderId-Marke hinzu. Aktualisiere deine Datenbankschicht, damit sie die Konstruktoren anwendet, wenn Zeilen hereinkommen. Schau, wie es sich anfühlt. Wenn es ein falsches Argument im Code Review abfängt, hat es sich bereits amortisiert.