Branded Types and Nominal Typing Guide
Branded types use TypeScript's type system to create distinct, nominal types that are structurally identical but semantically different. By attaching an invisible "brand" property to a type, you can distinguish between two strings that both represent IDs but belong to different domains (e.g., UserId vs OrderId), catching accidental misuse at compile time. This pattern prevents the common bug where a string intended for one purpose is passed to a component expecting a different string type, even if both are strings at runtime.
The Problem: Structural Typing Pitfalls
TypeScript uses structural typing by default, meaning two types are compatible if their structure matches, regardless of intent. This creates bugs where an OrderId (really just a string) is accidentally passed where a UserId is expected:
type UserId = string;
type OrderId = string;
interface UserCardProps {
userId: UserId;
}
interface OrderPageProps {
orderId: OrderId;
}
const userId: UserId = "user-123";
const orderId: OrderId = "order-456";
// ✗ Bug: TypeScript allows this, but semantically wrong
<UserCard userId={orderId} />;
Both UserId and OrderId are just string, so the compiler sees no type error. At runtime, you might try to fetch user data with an order ID, causing a 404 error. Branded types solve this by adding a nominal layer.
Creating and Using Branded Types
A branded type adds a hidden property using TypeScript's & { readonly __brand: symbol } pattern. This property doesn't exist at runtime but prevents structural type assignment:
// Create a branded type for UserId
type UserId = string & { readonly __brand: "UserId" };
// Helper function to safely create a UserId
const createUserId = (id: string): UserId => {
// At runtime, this is just a string; the brand is a compile-time guarantee
return id as UserId;
};
// Define other branded types similarly
type OrderId = string & { readonly __brand: "OrderId" };
const createOrderId = (id: string): OrderId => {
return id as OrderId;
};
interface UserCardProps {
userId: UserId;
onDelete?: (userId: UserId) => void;
}
export const UserCard = ({ userId, onDelete }: UserCardProps) => (
<div>
User ID: {userId}
{onDelete && <button onClick={() => onDelete(userId)}>Delete</button>}
</div>
);
const userId = createUserId("user-123");
const orderId = createOrderId("order-456");
// ✓ Correct usage
<UserCard userId={userId} />;
// ✗ Type error: OrderId is not assignable to UserId
<UserCard userId={orderId} />;
// ✓ Delete handler receives the correct branded type
<UserCard userId={userId} onDelete={(id) => console.log(id)} />;
The key insight is that the constructor functions (createUserId, createOrderId) perform runtime validation or formatting if needed, then cast to the branded type. At compile time, TypeScript enforces that only UserId values reach UserCard components.
Advanced: Multi-Part Brands
For more complex scenarios, you can extend branded types with multiple brand properties or nest brands within generics:
// Distinguish user types
type AdminId = string & { readonly __brand: "AdminId" };
type RegularUserId = string & { readonly __brand: "RegularUserId" };
// Create a generic branded type factory
type Branded<T, B extends string> = T & { readonly __brand: B };
type ApiEndpoint = Branded<string, "ApiEndpoint">;
type DatabaseQuery = Branded<string, "DatabaseQuery">;
const createApiEndpoint = (url: string): ApiEndpoint => {
if (!url.startsWith("/api/")) {
throw new Error("API endpoints must start with /api/");
}
return url as ApiEndpoint;
};
interface ApiClientProps {
endpoint: ApiEndpoint;
method: "GET" | "POST";
}
export const ApiClient = ({ endpoint, method }: ApiClientProps) => (
<div>
Calling {method} {endpoint}
</div>
);
const goodEndpoint = createApiEndpoint("/api/users");
const badString = "/users"; // Plain string, not an ApiEndpoint
// ✓ Type-safe
<ApiClient endpoint={goodEndpoint} method="GET" />;
// ✗ Error: string is not assignable to ApiEndpoint
<ApiClient endpoint={badString} method="GET" />;
Branded Types vs. Discriminated Unions
For complex data structures, discriminated unions (unions with a literal type field) sometimes work better than brands. Discriminated unions are more explicit and support more complex logic:
| Feature | Branded Type | Discriminated Union |
|---|---|---|
| Simple IDs / primitives | Excellent | Overkill |
| Complex objects | Awkward | Excellent |
| Compile-time only | Yes | No (requires runtime field) |
| Serialization | Transparent | Includes discriminator in JSON |
| Narrowing | Limited | Pattern matching via type guards |
Key Takeaways
- Branded types add a nominal layer to structurally identical types, distinguishing them at compile time without runtime overhead.
- Use the
T & { readonly __brand: "Name" }pattern to create brands, paired with constructor functions for validation. - Branded types prevent accidental type misuse (e.g., passing OrderId where UserId is expected).
- Constructor functions enforce invariants before casting to the branded type, combining type safety and validation.
- Brands are erased at runtime and have zero performance cost; they exist purely for compile-time type checking.
Frequently Asked Questions
Do branded types add any runtime overhead?
No. The __brand property is never instantiated. TypeScript erases it during transpilation. Branded types are purely a compile-time feature and have zero runtime cost.
How do I serialize branded types to JSON?
Since the brand is invisible at runtime, serialization is transparent. A UserId serializes as a plain string. If you need the brand information in JSON, use a discriminated union instead (which includes a literal type field).
Can I use branded types with generics?
Yes. You can create a generic branded type factory like type Branded<T, B> = T & { readonly __brand: B }. This allows reusable branded type creation across your codebase.
What if I accidentally use as to cast to the wrong brand?
The as keyword bypasses type checking, so misuse is possible. To mitigate, keep brand constructor functions in a controlled location and avoid casting elsewhere in your code. Code review and linting can enforce this.