TypeScript vergleicht Typen nach ihrer Form, also sind zwei Aliase von string austauschbar. Ein Branded Type fügt eine Markierung hinzu, die nur im Typsystem existiert, string & { readonly __brand: "UserId" }, und macht eine UserId damit inkompatibel mit einem einfachen String und mit jedem anderen Brand:
Zur Laufzeit ist userId einfach der String "u_42". Der Brand ist ein Etikett beim Kompilieren, und seine einzige Aufgabe ist es, zu verhindern, dass IDs, Einheiten und validierte Strings verwechselt werden.
Das Problem: Aliase sind nur Namen
Ein Type Alias erzeugt keinen neuen Typ. Er gibt einem bestehenden Typ einen zweiten Namen, und der Compiler behandelt beide Namen als dasselbe:
Das ist strukturelle Typisierung: TypeScript prüft, ob die Form passt, und string passt zu string. Bei Objekten unterscheiden sich die Formen meist; bei IDs, E-Mail-Adressen, Währungen und Einheiten nie. Brands lösen genau diesen einen Fall.
Wie der Brand funktioniert
string & { readonly __brand: "UserId" } ist eine Intersection: Ein Wert muss ein String sein und zusätzlich eine Eigenschaft __brand vom Typ "UserId" haben. Kein echter String hat diese Eigenschaft, also lässt sich kein einfacher String zuweisen:
index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
Die Richtung, auf die es ankommt, funktioniert weiterhin: Eine UserId ist ein String, also kannst du sie an alles übergeben, was einen String nimmt, .startsWith() darauf aufrufen oder sie in ein Template setzen. Der Brand versperrt nur den Weg hinein.
Konstruktorfunktionen, die validieren
Die Assertion as UserId ist der einzige Weg hinein, und eine Assertion prüft nichts. Setze sie in eine Funktion, die die Eingabe validiert, dann ist von jedem gebrandeten Wert im Programm bekannt, dass er diese Prüfung bestanden hat:
sendWelcome prüft seine Eingabe nie wieder, weil sein Parametertyp sagt, dass die Prüfung schon passiert ist. Das ist die Idee "parse, don't validate": an der Grenze prüfen, dann den Nachweis im Typ weitertragen. Ein Type Guard eignet sich ebenfalls als Konstruktor, wenn dir ein Boolean lieber ist als eine Exception: function isEmail(s: string): s is Email.
Ein generischer Brand-Helfer
Die Intersection für jeden Typ von Hand zu schreiben, wird schnell eintönig. Ein kleines Generic erledigt das einmal:
Eine Zahl zu branden funktioniert genauso wie einen String zu branden. Beachte, dass amount / 100 ein einfaches number ist: Arithmetik mit einer gebrandeten Zahl ergibt ein Ergebnis ohne Brand, dazu weiter unten mehr.
Brands mit unique symbol
Ein String-Eigenschaftsname wie __brand sieht aus wie eine echte Eigenschaft: userId.__brand besteht die Typprüfung als "UserId", ist zur Laufzeit aber undefined, und zwei Bibliotheken könnten denselben Namen wählen. Ein Schlüssel vom Typ unique symbol vermeidet beides:
declare const brand: unique symbol deklariert ein Symbol, das nur für den Type Checker existiert; das Schlüsselwort declare bedeutet, dass dafür kein JavaScript erzeugt wird. Weil das Symbol nicht aus seinem Modul exportiert wird, kann Code in anderen Dateien die Brand-Eigenschaft nicht einmal benennen. Außerhalb dieses Moduls kommst du also nur über die exportierten Funktionen oder über eine Assertion as Meters an ein Meters.
Brands kosten zur Laufzeit nichts
Die kompilierte Ausgabe enthält keine Spur des Brands. Das sind die Zeilen, die für das Meters-Beispiel erzeugt werden, unterhalb des Modul-Headers, den der Compiler hinzufügt: Das declare, beide Type Aliases und jedes as sind verschwunden, und der unterdrückte Aufruf in der letzten Zeile läuft trotzdem.
function toMeters(feet) {
return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);
Ein gebrandeter Wert ist der einfache primitive Wert: typeof ergibt "string" oder "number", JSON.stringify schreibt ihn wie gewohnt, und Vergleiche funktionieren wie vorher. Die Kehrseite: Zur Laufzeit wird nichts geprüft, außer deine Konstruktorfunktion prüft es. Daten aus JSON, einer Datenbank oder einer URL kommen als string an und werden erst zu einer UserId, wenn du sie durch diese Funktion schickst.
Arithmetik und Methoden verlieren den Brand
Operationen auf einem gebrandeten Wert geben den Basistyp zurück, weil der Brand nicht zu dem gehört, was + oder .slice() erzeugen:
type Cents = number & { readonly __brand: "Cents" };
const a = 500 as Cents;
const b = 250 as Cents;
const sum = a + b; // number, not Cents
const total: Cents = a + b; // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid
Meist ist das genau richtig: Zwei Beträge in Cent zu addieren, ergibt Cent, Cent mal Cent aber nicht, und nur du weißt, welche Operationen die Bedeutung erhalten. Schreibe kleine Helfer wie addCents(a: Cents, b: Cents): Cents für die Operationen, die dein Code braucht.
Wann Branded Types sinnvoll sind
Nutze Brands dort, wo die Verwechslung zweier Werte desselben primitiven Typs ein echtes Risiko ist und der Compiler sonst nicht helfen kann:
| Situation | Beispiel-Brands |
|---|---|
| IDs aus verschiedenen Tabellen | UserId, OrderId, ProductId |
| Validierte Strings | Email, Url, NonEmptyString, Slug |
| Einheiten und Währungen | Meters, Feet, Cents, Usd, Eur |
| Bereinigter oder escapeter Text | SafeHtml, SqlIdentifier |
| Zahlen mit Wertebereich | Percentage, PositiveInt |
Lass sie weg bei Werten, die nie verwechselt werden, und bei Objekttypen, die sich in ihrer Form schon unterscheiden. Validierungsbibliotheken können Branded Types aus einem Schema erzeugen: In Zod liefert z.string().brand<"UserId">() ein Schema, dessen parse eine gebrandete UserId zurückgibt, und das erspart dir die Konstruktorfunktionen von Hand.
Häufig gestellte Fragen
Was sind Branded Types in TypeScript?
Ein Muster, das zwei Typen mit derselben Darstellung zur Laufzeit inkompatibel macht. Du bildest eine Intersection aus dem Basistyp und einer Markierung, die kein gewöhnlicher Wert hat: type UserId = string & { readonly __brand: "UserId" }. Ein einfacher String oder eine OrderId mit anderer Markierung wird dann abgelehnt, wo eine UserId erwartet wird.
Hat TypeScript nominale Typen?
Nein. Das Typsystem von TypeScript ist strukturell: Zwei Typen mit derselben Form sind austauschbar, egal wie sie heißen. Klassendeklarationen mit Membern private oder #private verhalten sich nominal, und Branded Types sind der übliche Weg, denselben Effekt für primitive Typen wie Strings und Zahlen zu erreichen.
Kosten Branded Types etwas zur Laufzeit?
Nein. Der Brand existiert nur im Typ. Der Wert ist zur Laufzeit weiterhin ein einfacher String oder eine Zahl, ohne zusätzliche Eigenschaft, und das kompilierte JavaScript ist dasselbe wie ohne Brand. Der einzige Laufzeitcode ist die Validierung, die du selbst in die Funktion schreibst, die gebrandete Werte erzeugt.
Wie erzeuge ich einen Wert eines Branded Types?
Mit einer Type Assertion, idealerweise in einer kleinen Funktion, die die Eingabe vorher prüft: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Steht das as nur an dieser einen Stelle, hat jede Email im Programm die Prüfung durchlaufen.
Was ist der Unterschied zwischen einem Type Alias und einem Branded Type?
type UserId = string ist nur ein neuer Name: Jeder String wird akzeptiert, wo eine UserId erwartet wird. type UserId = string & { readonly __brand: "UserId" } ist ein neuer, inkompatibler Typ: Ein einfacher String muss vorher durch eine Konstruktorfunktion oder eine Assertion.