Branded Types: Nominal Typing in einem strukturellen Typsystem
<T>
type
TypeScript · Branded Types · Nominal Typing · Typsicherheit
Branded Types: Nominal Typing in einem strukturellen Typsystem
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.

11 Min. Lesezeit Brand-Muster · Nominal Typing · Opaque Types TypeScript 5.x

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.

11. FAQ: Branded Types in TypeScript

1Was ist ein Branded Type in TypeScript?
Ein Basistyp, der per Intersection-Type um eine rein virtuelle Tag-Property erweitert wird, die nur zur Kompilierzeit existiert und den Typ unterscheidbar macht.
2Warum reicht ein normaler string-Typ für IDs nicht aus?
TypeScript prüft strukturell, nicht nominal. Zwei string-IDs wie UserId und OrderId sind für den Compiler identisch und lassen sich unbemerkt vertauschen.
3Kosten Branded Types etwas zur Laufzeit?
Nein. Die Tag-Property verschwindet beim Kompilieren vollständig, ein Branded-Type-Wert bleibt zur Laufzeit ein gewöhnliches Primitiv.
4Wie erzeugt man einen Wert eines Branded Type sicher?
Über eine Fabrikfunktion, die den rohen Wert validiert und erst danach per Type Assertion umwandelt, als einzige berechtigte Stelle im Modul.
5Branded Type oder Wrapper-Klasse?
Beide bieten Nominal Typing, aber die Wrapper-Klasse alloziert ein echtes Objekt. Der Branded Type verschwindet beim Kompilieren restlos.
6Wofür eignen sich Branded Types bei validierten Strings?
Für Werte wie Email, bei denen ein Type Guard einmalig validiert und tiefere Codeschichten sich danach auf die Gültigkeit verlassen können.
7Wie verhindern Branded Types Fehler bei Geldbeträgen?
Cents und Dollars als eigene Brands lassen eine Zahlungsfunktion nur Cents akzeptieren, ein vertauschter Betrag löst einen Compile-Fehler aus.
8Kann eine Type Assertion den Brand-Schutz umgehen?
Technisch ja. Der Schutz beruht auf Disziplin: Nur die Fabrikfunktion sollte casten, idealerweise unterstützt durch Lint-Regeln.
9Was bedeutet unique symbol als Brand-Schlüssel?
Ein privates, modul-internes unique symbol statt eines String-Literals erschwert das versehentliche Nachbilden des Brands von außen.
10Wann lohnen sich Branded Types nicht?
Bei einmalig verwendeten, unkritischen Werten ohne realistisches Verwechslungsrisiko, wo ein einfacher string völlig ausreicht.