Zum Inhalt springen

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.

5 Min. Lesezeit
TypeScript-Code, der Branded-Type-Deklarationen für die Domänentypen UserId, OrderId und Cents zeigt

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.

tstypescript
// ❌ 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.

tstypescript
// 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.

tstypescript
// ✅ 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.

tstypescript
// 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.

tstypescript
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.

tstypescript
// ❌ 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 swapped

Der 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.

tstypescript
// 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.

SzenarioBesserer AnsatzWarum
Komplexe Formatvalidierung (E-Mail, URL)Zod-Schema mit .brand()-AusgabeValidierungslogik passt nicht in einen Typ
Domänenobjekte mit InvariantenKlasse mit privatem KonstruktorEs werden Methoden benötigt, nicht nur ein typisiertes Primitive
Zustandsübergänge einer EntitätDiskriminierte UnionMehrere Formen, kein einzelner Skalar
Aufzählungenconst-Enum oder Union-LiteralGeschlossene Menge bekannter Werte
Serviceübergreifende API-VerträgeGenerierte Typen aus OpenAPISchema-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

  1. Strukturelle Typisierung macht Primitives standardmäßig austauschbar — jeder string kann jeden anderen string ersetzen, unabhängig von der Domänenabsicht
  2. Branded Types kosten zur Laufzeit nichts — die Phantom-Eigenschaft ist reine Typ-Ebenen-Fiktion; kein Overhead, keine zusätzlichen Bytes
  3. Beschränken Sie as-Casts auf zwei Stellen: Smart Constructors an den I/O-Grenzen und Row-Mapper im Repository — sonst nirgendwo
  4. Zods .brand()-Methode macht schemagetriebenes Branding zum natürlichen Standard für Zod-first-Codebasen
  5. Numerische Brands werden zu selten genutzt — Cents, Percentage und UnixTimestamp verhindern stille Einheiten-Fehler, die in Produktion nur mühsam aufzuspüren sind
  6. 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
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX