Warum zwei gleich geformte Strings noch lange nicht dasselbe bedeuten
TypeScript prüft Typen anhand ihrer Form, nicht anhand ihres Namens. Eine UserId und eine OrderId sind für den Compiler identisch, solange beide als string deklariert sind, und lassen sich deshalb versehentlich vertauschen. Branded Types schließen genau diese Lücke, indem sie einem strukturellen Typsystem eine nominale Unterscheidung hinzufügen, komplett ohne Laufzeitkosten.
Inhaltsverzeichnis
- 1. Das Problem: Strukturelle Typisierung lässt IDs verwechseln
- 2. Grundlagen: Was sind Branded Types?
- 3. Implementierung: Intersection-Type mit Tag-Property
- 4. Sichere Konstruktion: Fabrikfunktionen und Validierung
- 5. Praxisfall: IDs verschiedener Entitäten unterscheiden
- 6. Praxisfall: Validierte und bereinigte Strings
- 7. Praxisfall: Geldbeträge und Einheiten
- 8. Laufzeitkosten: Warum Branded Types nichts kosten
- 9. Branded Types im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem: Strukturelle Typisierung lässt IDs verwechseln
TypeScript verwendet ein strukturelles Typsystem: Zwei Typen gelten als kompatibel, sobald sie dieselbe Form haben, unabhängig davon, wie sie benannt sind oder wofür sie inhaltlich stehen. Das ist in den meisten Fällen ein Vorteil, weil es Duck Typing erlaubt und Interfaces flexibel kombinierbar macht. Bei einfachen Primitiven wie string oder number wird dieser Vorteil aber schnell zur Falle: Eine Funktion function cancelOrder(orderId: string) nimmt anstandslos auch eine UserId, eine E-Mail-Adresse oder einen beliebigen anderen String entgegen, solange der Typ nur string lautet.
In einer wachsenden Codebasis mit vielen verschiedenen ID-Typen, validierten Eingabeformaten und Geldbeträgen summiert sich dieses Risiko. Ein vertauschter Parameter bei transferFunds(fromAccountId, toAccountId) fällt dem Compiler nicht auf, weil beide Parameter denselben Typ string tragen, und wird im schlimmsten Fall erst in Produktion sichtbar. Branded Types lösen dieses Problem, indem sie strukturell identischen Primitiven eine zusätzliche, nur zur Kompilierzeit sichtbare Kennzeichnung mitgeben, die der Compiler bei jeder Zuweisung prüft.
2. Grundlagen: Was sind Branded Types?
Branded Types, auch Tagged Types oder Nominal Types genannt, simulieren nominale Typisierung innerhalb eines strukturellen Typsystems. Nominale Typisierung bedeutet, dass zwei Typen nur dann kompatibel sind, wenn sie explizit denselben Namen tragen, so wie es Sprachen mit nominalem Typsystem wie Java oder C# standardmäßig handhaben. TypeScript besitzt dieses Konzept nicht nativ, lässt sich aber mit einem einfachen Kniff nachbilden: Man erweitert einen Basistyp um eine zusätzliche, rein virtuelle Property, die zur Laufzeit nie existiert.
Das Ergebnis ist ein Typ, der sich für den Compiler wie ein eigenständiger, unverwechselbarer Typ verhält, obwohl er zur Laufzeit ein ganz gewöhnlicher string oder number bleibt. Zwei Branded Types mit unterschiedlichem Tag, etwa UserId und OrderId, sind für den Compiler nicht mehr gegenseitig zuweisbar, selbst wenn ihr struktureller Unterbau identisch ist. Genau dieser künstliche Unterschied ist der gesamte Zweck des Musters.
3. Implementierung: Intersection-Type mit Tag-Property
Die gängigste Implementierung kombiniert den Basistyp per Intersection-Type mit einem Objekt, das eine einzelne, eindeutig benannte Property trägt, meist __brand oder __tag. Ein generischer Helfer wie type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand } macht dieses Muster wiederverwendbar für beliebige Basistypen und Tag-Namen. Wichtig ist: Diese Property wird niemals mit echten Werten befüllt, sie existiert ausschließlich, damit der Compiler zwei sonst identische Typen als inkompatibel behandelt.
Weil ein normaler string kein __brand-Feld besitzt, akzeptiert TypeScript einen rohen String nicht automatisch als UserId, das Zuweisen erfordert eine explizite, sichtbare Type Assertion. Diese Reibung ist beabsichtigt: Sie zwingt dazu, den Übergang von unstrukturierten Eingaben zu vertrauenswürdigen, gebrandeten Werten an einer einzigen, bewusst gewählten Stelle im Code zu vollziehen, statt überall im System auf Zuruf zu casten.
// Brand pattern: intersect the base type with a unique tag property
type Brand<T, TBrand extends string> = T & { readonly __brand: TBrand };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
declare function getUser(id: UserId): void;
const rawId: string = "usr-123";
// getUser(rawId); // compile error: string is not assignable to UserId
// getUser(rawId as UserId); // works, but requires an explicit, visible cast
4. Sichere Konstruktion: Fabrikfunktionen und Validierung
Eine rohe Type Assertion wie rawId as UserId löst das Vertauschungsproblem, garantiert aber für sich genommen keine inhaltliche Korrektheit, denn der Compiler prüft nur die Form, nicht den tatsächlichen Wert. In der Praxis kombiniert man Branded Types deshalb fast immer mit einer Fabrikfunktion, die als einzige Stelle im Code berechtigt ist, einen Branded Type zu erzeugen, und die dabei eine echte Laufzeitprüfung durchführt.
Diese Fabrikfunktion wird typischerweise exportiert, während der zugrunde liegende Konstruktor der Type Assertion privat im selben Modul bleibt. Für den Rest der Codebasis existiert dann kein Weg mehr, einen UserId-Wert zu erzeugen, außer über toUserId(), was Validierung und Typsicherheit an einer einzigen, klar auffindbaren Stelle bündelt, statt sie über die gesamte Anwendung zu verteilen.
// Factory function is the only place allowed to mint a branded value
function toUserId(value: string): UserId {
if (!value.startsWith("usr-")) {
throw new Error(`Invalid UserId: ${value}`);
}
return value as UserId;
}
function toOrderId(value: string): OrderId {
if (!value.startsWith("ord-")) {
throw new Error(`Invalid OrderId: ${value}`);
}
return value as OrderId;
}
const userId = toUserId("usr-123"); // UserId
const orderId = toOrderId("ord-456"); // OrderId
5. Praxisfall: IDs verschiedener Entitäten unterscheiden
Der häufigste und lohnendste Einsatzbereich für Branded Types sind Identifikatoren. Sobald eine Anwendung mehr als eine Entität mit ID besitzt, etwa Kunden, Bestellungen und Produkte, wird jede als string typisierte ID-Signatur zu einer stillen Fehlerquelle, weil eine CustomerId strukturell nicht von einer ProductId zu unterscheiden ist. Repositories und Service-Methoden, die stattdessen Branded Types wie UserId und OrderId erwarten, lehnen eine Vertauschung bereits beim Kompilieren ab.
Besonders wertvoll ist dieser Schutz an Schnittstellen mit vielen ähnlich aussehenden Parametern, etwa Funktionen mit drei oder vier ID-Argumenten hintereinander. Ohne Branded Types verlässt man sich hier vollständig auf Code-Reviews und Tests, um eine falsche Reihenfolge zu bemerken. Mit Branded Types übernimmt der Compiler diese Prüfung automatisch und zuverlässig, bei jedem einzelnen Aufruf, ohne zusätzlichen Testaufwand.
interface OrderRepository {
findByCustomer(customerId: UserId): Order[];
findById(orderId: OrderId): Order | undefined;
}
declare const repository: OrderRepository;
declare type Order = { id: OrderId; customerId: UserId };
const customer = toUserId("usr-123");
const order = toOrderId("ord-456");
repository.findByCustomer(customer); // fine
// repository.findByCustomer(order); // compile error: OrderId is not assignable to UserId
// A plain "string" parameter would have accepted this silently
6. Praxisfall: Validierte und bereinigte Strings
Ein zweiter starker Anwendungsfall ist die Unterscheidung zwischen unvalidierten und bereits geprüften Strings. Eine Funktion sendWelcomeMail(to: string) kann nicht erzwingen, dass der übergebene Wert tatsächlich eine gültige E-Mail-Adresse ist, sie kann nur hoffen, dass irgendwo vorher validiert wurde. Ein Branded Type Email macht diese Hoffnung überflüssig: Nur wer den Wert erfolgreich durch eine Validierungsfunktion geschickt hat, besitzt überhaupt einen Wert vom Typ Email, und nur ein solcher Wert lässt sich an sendWelcomeMail übergeben.
Dasselbe Muster eignet sich für bereinigten HTML-Code, geprüfte Dateipfade oder normalisierte Postleitzahlen. Statt Validierung an jeder Verwendungsstelle erneut durchzuführen, geschieht sie einmalig beim Übergang von rohem zu gebrandetem String, und der Compiler garantiert danach, dass eine erneute Prüfung an tieferen Schichten der Anwendung überflüssig ist, weil ein nicht validierter Wert den Typ Email gar nicht erst annehmen kann.
type Email = Brand<string, "Email">;
function isEmail(value: string): value is Email {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function sendWelcomeMail(to: Email): void {
// implementation omitted, only reachable with a validated Email
}
function tryEmail(input: string): Email | undefined {
return isEmail(input) ? input : undefined;
}
const candidate = tryEmail("dev@mironsoft.de");
if (candidate) {
sendWelcomeMail(candidate); // narrowed to Email, no re-validation needed downstream
}
7. Praxisfall: Geldbeträge und Einheiten
Geldbeträge sind ein klassischer Fall, in dem ein einfacher number-Typ gefährlich unterspezifiziert ist: Steht der Wert für Cent oder für Euro, für Netto oder Brutto? Verwechslungen zwischen Cent und Euro gehören zu den häufigsten und teuersten Fehlern in Zahlungssystemen, gerade weil ein Faktor 100 auf den ersten Blick oft plausibel wirkt. Branded Types wie Cents und Dollars machen diese Einheit zu einem Typmerkmal, sodass eine Funktion, die eine Zahlung an einen Payment-Provider auslöst, ausschließlich Cents entgegennimmt und einen rohen oder in Dollar angegebenen Betrag ablehnt.
Die Umrechnung zwischen den Einheiten bleibt dabei jederzeit möglich, muss aber explizit über eine Konvertierungsfunktion erfolgen, die selbst wieder den passenden Branded Type zurückgibt. Dieselbe Technik funktioniert für beliebige physikalische oder fachliche Einheiten, etwa Meter gegenüber Millimeter oder Netto- gegenüber Bruttopreisen, überall dort, wo ein reiner number-Typ die eigentlich entscheidende Information über die Einheit stillschweigend verschluckt.
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;
function centsToDollars(value: Cents): Dollars {
return (value / 100) as Dollars;
}
function dollarsToCents(value: Dollars): Cents {
return Math.round(value * 100) as Cents;
}
function chargeCard(amount: Cents): void {
// the payment provider always expects the smallest currency unit
}
const price: Dollars = 19.99 as Dollars;
chargeCard(dollarsToCents(price)); // explicit, deliberate conversion
// chargeCard(price); // compile error: Dollars is not assignable to Cents
8. Laufzeitkosten: Warum Branded Types nichts kosten
Ein entscheidender Vorteil von Branded Types gegenüber Alternativen wie Wrapper-Klassen ist, dass sie zur Laufzeit vollständig verschwinden. Die Tag-Property __brand existiert ausschließlich im Typsystem und wird beim Kompilieren zu JavaScript restlos entfernt, so wie jede andere reine Typinformation auch. Ein UserId-Wert ist zur Laufzeit exakt derselbe primitive String wie jeder andere string, ohne zusätzliches Objekt, ohne zusätzliche Property, ohne jede messbare Allokation.
Das unterscheidet Branded Types grundlegend von einer Wrapper-Klasse wie class UserId { constructor(public value: string) {} }, die zwar dieselbe Nominal-Typing-Garantie bietet, dafür aber bei jeder Erzeugung ein echtes Objekt auf dem Heap alloziert, Vergleiche über === erschwert und JSON-Serialisierung zusätzlichen Aufwand abverlangt. Branded Types liefern die Compile-Zeit-Sicherheit einer Wrapper-Klasse zum Nulltarif einer reinen Typannotation, ein seltener Fall, in dem mehr Typsicherheit nicht mit mehr Laufzeitkosten erkauft werden muss.
9. Branded Types im Vergleich
Nicht jede Stelle im Code profitiert von einem Branded Type. Die folgende Übersicht zeigt typische Szenarien und macht deutlich, wann sich der zusätzliche Aufwand für ein Tag-Muster tatsächlich auszahlt.
| Szenario | Ohne Branded Types | Mit Branded Types | Vorteil |
|---|---|---|---|
| IDs verschiedener Entitäten | userId: string, orderId: string | UserId, OrderId als eigene Brands | Compiler verhindert versehentliches Vertauschen |
| Validierte Nutzereingaben | email: string ohne Prüfung | Email-Brand via Type Guard | Validierung einmalig, danach garantiert gültig |
| Geldbeträge und Einheiten | amount: number ohne Einheit | Cents, Dollars als eigene Brands | Verhindert Verwechslungen wie Cent statt Euro |
| Laufzeitkosten | Wrapper-Klasse mit eigener Instanz | Brand via Intersection-Type | Verschwindet vollständig beim Kompilieren |
| Schutz vor Nachbildung | String-literales Tag-Feld, leicht nachbaubar | unique symbol als Brand-Schlüssel | Brand lässt sich von außen nicht zufällig treffen |
Die Faustregel: Branded Types lohnen sich überall dort, wo mehrere strukturell identische Primitiven fachlich unterschiedliche Dinge bedeuten, allen voran bei IDs, validierten Eingaben und Einheiten. Für einmalig verwendete, unkritische Strings oder Zahlen ohne Verwechslungsgefahr ist ein einfacher primitiver Typ weiterhin die richtige, einfachere Wahl.
Mironsoft
TypeScript-Tooling, Typsicherheit und typsichere Headless-Integrationen
IDs, Beträge und Eingaben, die sich nicht mehr vertauschen lassen?
Wir prüfen bestehende TypeScript-Typen auf stille Verwechslungsrisiken bei IDs, Geldbeträgen und Nutzereingaben, führen dort, wo es sich lohnt, Branded Types ein und bauen typsichere Domänenmodelle für euren Magento- und Headless-Stack.
Type-Level-Review
Bestehende Typen auf Verwechslungsrisiken bei IDs und Einheiten prüfen
Domänenmodellierung
Branded Types für IDs, validierte Strings und Geldbeträge einführen
Build-Tooling
Strikte tsconfig-Einstellungen und Typ-Checks in der CI-Pipeline
10. Zusammenfassung
Branded Types schließen eine konkrete Lücke des strukturellen Typsystems von TypeScript: Zwei Werte mit identischer Form, aber unterschiedlicher fachlicher Bedeutung, etwa eine UserId und eine OrderId, lassen sich sonst versehentlich vertauschen. Das Brand-Muster erweitert einen Basistyp per Intersection-Type um eine rein virtuelle Tag-Property, die nur zur Kompilierzeit existiert und den Compiler zwingt, beide Typen als inkompatibel zu behandeln. Fabrikfunktionen mit echter Laufzeitvalidierung sorgen dafür, dass ein Branded Type nicht nur formal, sondern auch inhaltlich vertrauenswürdig ist.
Die drei stärksten Praxisfälle sind IDs verschiedener Entitäten, validierte oder bereinigte Strings wie E-Mail-Adressen, und Geldbeträge mit unterschiedlichen Einheiten wie Cent und Euro. In allen drei Fällen verhindert der Compiler Fehler, die sonst erst in Tests oder in Produktion auffallen würden. Der entscheidende Vorzug gegenüber Wrapper-Klassen ist, dass die gesamte Tag-Property beim Kompilieren restlos verschwindet: volle Nominal-Typing-Sicherheit, ganz ohne Laufzeitkosten.
Branded Types in TypeScript - Das Wichtigste auf einen Blick
Grundmuster
T & { readonly __brand: TBrand } macht strukturell identische Typen für den Compiler inkompatibel.
Sichere Konstruktion
Fabrikfunktionen validieren zur Laufzeit und sind die einzige erlaubte Stelle, einen Branded Type zu erzeugen.
Praxisfälle
IDs, validierte Strings wie Email, und Geldbeträge mit Einheiten wie Cents und Dollars.
Laufzeitkosten
Null. Die Tag-Property existiert nur im Typsystem und verschwindet vollständig beim Kompilieren zu JavaScript.