Introduction
TypeScript uses structural typing: two types are compatible if their shapes match, regardless of their names. This is flexible but dangerous when you need to distinguish values that share the same primitive type. Branded types let you add compile-time identity to primitives so that a UserId can never be confused with an OrderId, even though both are strings underneath.
Key Concepts
- Structural Typing: Types are compatible based on shape, not name.
{ x: number }matches any object with a numericxproperty. - Nominal Typing: Types are compatible only when they share the same declared name. Languages like Java and C# use this model.
- Branded Type: A TypeScript pattern that simulates nominal typing by intersecting a base type with a phantom tag property.
Real World Context
In a payment system, passing a raw number for both USD and EUR amounts is a recipe for currency-conversion bugs. Branded types make it a compile error to pass a USD amount where EUR is expected, catching the mistake before it reaches production.
Deep Dive
TypeScript's structural type system treats these two types as fully interchangeable:
typescripttype UserId = string; type OrderId = string; function getUser(id: UserId) { /* ... */ } const orderId: OrderId = "order-123"; getUser(orderId); // No error — both are just string
This compiles without complaint because UserId and OrderId are both aliases for string. To fix this, we brand each type with a unique phantom property:
typescripttype Brand<Base, Tag extends string> = Base & { readonly __brand: Tag }; type UserId = Brand<string, "UserId">; type OrderId = Brand<string, "OrderId">; function getUser(id: UserId) { /* ... */ } const orderId = "order-123" as OrderId; // getUser(orderId); // Error: 'OrderId' is not assignable to 'UserId'
The __brand property never exists at runtime — it is purely a compile-time tag. The intersection string & { __brand: "UserId" } creates a type that is structurally incompatible with string & { __brand: "OrderId" }, giving us nominal-like behavior.
A generic Brand utility makes this reusable across your entire codebase:
typescripttype EUR = Brand<number, "EUR">; type USD = Brand<number, "USD">; function euroToUsd(amount: EUR): USD { return (amount * 1.1) as USD; } const price = 100 as EUR; const converted = euroToUsd(price); // OK // euroToUsd(100); // Error: number is not EUR
The as cast is required to create branded values because plain primitives lack the phantom property.
Common Pitfalls
- Forgetting the cast — You must use
as UserIdor a smart constructor to create branded values. A plain string will not type-check. - Using
__brandat runtime — The brand property is a compile-time fiction. Never checkif (value.__brand)in runtime code; it will always beundefined.
Best Practices
- Use a single Brand utility — Define
type Brand<B, T> = B & { readonly __brand: T }once and reuse it everywhere for consistency. - Prefer smart constructors over raw casts — Wrap the
ascast inside a validation function (covered in the next lesson) to prevent invalid branded values.
Summary
- TypeScript is structurally typed; type aliases like
type UserId = stringoffer no safety. - Branded types use a phantom intersection property to simulate nominal typing.
- The brand exists only at compile time and has zero runtime cost.
- Use a generic
Brand<Base, Tag>utility for consistency across the codebase.
Code Examples
// Generic Brand utility
type Brand<Base, Tag extends string> = Base & { readonly __brand: Tag };
// Domain-specific branded types
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// Creating branded values requires an explicit cast
const userId = "usr-42" as UserId;
const orderId = "ord-99" as OrderId;
// Type-safe function — only accepts UserId
function fetchUser(id: UserId): void { /* ... */ }
fetchUser(userId); // OK
// fetchUser(orderId); // Error: OrderId is not assignable to UserId