TypeScript Branded Types: Domänenmodellierung zur Compile-Zeit
Eliminiere eine ganze Klasse von Laufzeitfehlern, indem TypeScript UserId und OrderId unterscheiden lernt — auch wenn beide intern Strings sind.

Die meisten TypeScript-Codebasen behandeln Domänen-Identifikatoren als rohe Primitives. Eine userId ist ein string. Eine orderId ist ein string. Der amount einer Transaktion ist eine number. TypeScripts strukturelles Typsystem sieht sie als identische Formen, sodass die Übergabe einer orderId an eine Stelle, die eine userId erwartet, klaglos kompiliert — bis die Datenbank um 2 Uhr nachts "user not found" zurückgibt und man eine Stunde damit verbringt, ein vertauschtes Argument aufzuspüren.
Das ist Primitive Obsession, und Branded Types eliminieren diese gesamte Problemklasse zur Compile-Zeit, ganz ohne Laufzeitkosten.
Der Compiler rettet Sie nicht (zumindest nicht standardmäßig)
TypeScript verwendet strukturelle Typisierung: Zwei Typen sind kompatibel, wenn sie dieselbe Form teilen. Bei Primitives ist jeder string mit jedem anderen string kompatibel. Für ein universelles Typsystem ist das korrektes Verhalten, aber es hinterlässt eine Lücke zwischen dem, was Ihr Domänenmodell bedeutet, und dem, was der Type-Checker tatsächlich durchsetzen kann.
// ❌ All three arguments are strings — the compiler can't distinguish them
async function transferFunds(
fromAccountId: string,
toAccountId: string,
referenceId: string,
): Promise<void> {
await ledger.debit(fromAccountId, referenceId); // Accidentally used referenceId here
}
// This call compiles without complaint — the arguments are in the wrong order
transferFunds(toId, fromId, accountId);Keiner dieser Fehler taucht als Typfehler auf. Sie zeigen sich als fehlerhaftes Verhalten in Produktion. Das Typsystem hätte die nötigen Informationen, um sie abzufangen — es muss nur die Domänensemantik in die Typen codiert werden.
Branded Types: das Kernmuster
Ein Branded Type umschließt ein Primitive mit einem Phantom-Type-Tag. Dieses Tag ist zur Laufzeit unsichtbar — TypeScript entfernt es nach der Typprüfung —, macht aber strukturell identische Primitives für den Compiler inkompatibel.
// The foundation — a generic brand utility
type Brand<T, B extends string> = T & { readonly __brand: B };
// Domain identifiers
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type AccountId = Brand<string, "AccountId">;
// Domain scalars with units
type Cents = Brand<number, "Cents">;
type Percentage = Brand<number, "Percentage">;
type UnixTimestamp = Brand<number, "UnixTimestamp">;Die Eigenschaft __brand existiert bei keinem tatsächlichen Objekt. Sie existiert ausschließlich im Typsystem. Sobald Sie sie hinzufügen, sind UserId und OrderId strukturell verschieden, und TypeScript verweigert die Annahme des einen dort, wo das andere erwartet wird.
// ✅ Type-safe transfer — wrong ID order is a compile-time error
async function transferFunds(
from: AccountId,
to: AccountId,
amount: Cents,
): Promise<void> {
// ...
}
declare const userId: UserId;
declare const accountId: AccountId;
declare const amount: Cents;
transferFunds(accountId, accountId, amount); // ✅ Compiles
transferFunds(userId, accountId, amount);
// Error: Argument of type 'UserId' is not assignable to parameter of type 'AccountId'.Branded-Werte an der Grenze erzeugen
Die Regel ist einfach: Branded-Werte entstehen an genau zwei Stellen — bei der Validierung von HTTP-/RPC-Eingaben und beim Mapping von Datenbank-Lesezugriffen. Überall sonst im Code werden bereits gebrandete Werte entgegengenommen, und es wird nie gecastet.
// Smart constructors — validation and branding happen together
function parseUserId(raw: unknown): UserId | null {
if (typeof raw !== "string" || raw.length < 10 || raw.length > 36) return null;
return raw as UserId; // The only place we use `as` for this type
}
function parseCents(raw: unknown): Cents | null {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) return null;
return raw as Cents;
}
// API route handler — brand at the entry point
export async function POST(req: Request) {
const body = await req.json();
const userId = parseUserId(body.userId);
const amount = parseCents(body.amount);
if (!userId || !amount) {
return Response.json({ error: "Invalid input" }, { status: 400 });
}
// From here on, all downstream functions receive typed domain values
await paymentService.processPayment(userId, amount);
}Wenn Sie sich dabei ertappen, as UserId innerhalb der Service- oder
Repository-Logik zu schreiben, hält die Grenze nicht mehr. Dieser Cast gehört
in den Konstruktor oder das Schema — nicht verstreut in der Geschäftslogik.
Integration mit Zod
Wer Ein- und Ausgaben bereits mit Zod validiert, kann Brands sauber über .brand() einbinden. Validierung und nominale Typisierung finden dann in einer einzigen Schema-Definition statt.
import { z } from "zod";
const UserIdSchema = z.string().min(10).max(36).brand<"UserId">();
const OrderIdSchema = z.string().uuid().brand<"OrderId">();
const CentsSchema = z.number().int().nonnegative().brand<"Cents">();
// Types are derived from schemas — no duplication
type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;
type Cents = z.infer<typeof CentsSchema>;
const CreateOrderSchema = z.object({
userId: UserIdSchema,
amount: CentsSchema,
});
// Parsing produces branded types automatically
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) return res.status(400).json(result.error.flatten());
// result.data.userId is UserId, result.data.amount is Cents
await orderService.create(result.data.userId, result.data.amount);Zods .brand() verwendet intern denselben Phantom-Type-Mechanismus. Das ist der bevorzugte Ansatz, wenn Sie ohnehin in einer stark auf Zod setzenden Codebasis arbeiten — das Schema wird zur einzigen Quelle der Wahrheit, sowohl für die Form als auch für die Domänenidentität.
Numerische Brands für Finanz- und Zeitlogik
String-Identifikatoren bekommen die meiste Aufmerksamkeit, aber numerische Brands sind der Punkt, an dem dieses Muster die schmerzhaftesten Fehler verhindert. Das Vermischen ungebrandeter number-Werte, die unterschiedliche Einheiten darstellen — Dollar versus Cent, Prozent versus Dezimalwert, Sekunden versus Millisekunden —, ist ein stiller Korrektheitsfehler.
// ❌ Ambiguous — is discount 0.15 or 15? The caller has to read the implementation
function applyDiscount(price: number, discount: number): number {
return Math.round(price * (1 - discount));
}
// ✅ The types document and enforce the expected units
function applyDiscount(price: Cents, discount: Percentage): Cents {
return Math.round(price * (1 - discount / 100)) as Cents;
}
const price = 2000 as Cents; // $20.00 in cents
const discount = 15 as Percentage; // 15%, not 0.15
applyDiscount(price, discount); // ✅ Returns 1700 Cents = $17.00
applyDiscount(discount, price); // ❌ Type error: arguments swappedDer gebrandete Percentage-Typ dient zugleich als Inline-Dokumentation dafür, dass die Funktion 15 erwartet, nicht 0.15. Diese Konvention wird vom Typ erzwungen, nicht von einem Kommentar, der mit der Zeit veralten kann.
Brands in der Repository-Schicht anwenden
Datenbanktreiber und ORMs liefern reine, ungebrandete string- und number-Werte zurück — beim Lesen aus dem Speicher brauchen Sie eine zweite Branding-Grenze, ebenso konsequent wie die HTTP-Grenze.
// Repository — brand on the way out, every time
async function findUserById(id: UserId): Promise<User | null> {
const row = await db
.selectFrom("users")
.where("id", "=", id) // UserId extends string, so this works
.selectAll()
.executeTakeFirst();
if (!row) return null;
// Apply brands at the mapping layer
return {
id: row.id as UserId,
email: row.email,
createdAt: row.created_at as UnixTimestamp,
};
}Der Cast bleibt an einer einzigen, expliziten Stelle — dem Row-Mapper — statt über die ganze Codebasis verstreut zu sein. Jeder Konsument von findUserById erhält korrekt typisierte Werte, ohne zu wissen, woher das Branding stammt.
Wann Branded Types nicht das richtige Werkzeug sind
Brands funktionieren gut für Skalare. Sie ersetzen kein reichhaltigeres Modellieren.
| Szenario | Besserer Ansatz | Warum |
|---|---|---|
| Komplexe Formatvalidierung (E-Mail, URL) | Zod-Schema mit .brand()-Ausgabe | Validierungslogik passt nicht in einen Typ |
| Domänenobjekte mit Invarianten | Klasse mit privatem Konstruktor | Es werden Methoden benötigt, nicht nur ein typisiertes Primitive |
| Zustandsübergänge einer Entität | Diskriminierte Union | Mehrere Formen, kein einzelner Skalar |
| Aufzählungen | const-Enum oder Union-Literal | Geschlossene Menge bekannter Werte |
| Serviceübergreifende API-Verträge | Generierte Typen aus OpenAPI | Schema-Abgleich zählt mehr als Branding |
Werden Brands übertrieben eingesetzt, breiten sich Casts aus. Wer as UserId für ein und denselben Typ an mehr als zwei oder drei Stellen in der gesamten Codebasis schreibt, bei dem hält die Konstruktionsgrenze nicht mehr.
Die wichtigsten Erkenntnisse
- Strukturelle Typisierung macht Primitives standardmäßig austauschbar — jeder
stringkann jeden anderenstringersetzen, unabhängig von der Domänenabsicht - Branded Types kosten zur Laufzeit nichts — die Phantom-Eigenschaft ist reine Typ-Ebenen-Fiktion; kein Overhead, keine zusätzlichen Bytes
- Beschränken Sie
as-Casts auf zwei Stellen: Smart Constructors an den I/O-Grenzen und Row-Mapper im Repository — sonst nirgendwo - Zods
.brand()-Methode macht schemagetriebenes Branding zum natürlichen Standard für Zod-first-Codebasen - Numerische Brands werden zu selten genutzt —
Cents,PercentageundUnixTimestampverhindern stille Einheiten-Fehler, die in Produktion nur mühsam aufzuspüren sind - Brands ersetzen kein reichhaltiges Domänenmodell — verwenden Sie diskriminierte Unions für Zustände, Klassen für Objekte mit Invarianten; Brands sind ausschließlich für Skalare gedacht


