Branded / Opaque Types in TypeScript
Also Known As
branded types
opaque types
nominal typing TypeScript
phantom types
TL;DR
A technique to make structurally identical types incompatible — a UserId and an OrderId are both strings, but branding makes them distinct types so passing an OrderId where UserId is expected is a compile-time error.
Explanation
TypeScript uses structural typing — two types with the same shape are interchangeable. 'type UserId = string' and 'type OrderId = string' are identical; any string satisfies either. Branded types add a phantom property that exists only at the type level, not at runtime: 'type UserId = string & { readonly __brand: unique symbol }'. Now UserId and OrderId are structurally different types. A plain string cannot be assigned to UserId without an explicit cast function, preventing accidental ID swaps. The brand property is never actually on the object — it is erased at runtime. A factory function performs the single trusted cast: 'function userId(id: string): UserId { return id as UserId; }'.
Common Misconception
✗ Branded types add runtime overhead. The brand is a phantom — it exists only in the TypeScript type system and is completely erased at compile time. There is zero runtime cost.
Why It Matters
Without branded types, swapping a UserId and an OrderId in a function call is a silent bug — TypeScript accepts it because both are strings. With branded types, the compiler catches the swap immediately. This is especially valuable in PHP-backed APIs where IDs are all integers or UUIDs that look identical to TypeScript.
Common Mistakes
- Using a string literal as the brand ('readonly __brand: 'UserId'') instead of unique symbol — string literals are structurally compatible across different branded types if they happen to share the same literal.
- Performing the 'as' cast directly at call sites instead of in factory functions — the whole point is that the cast happens in one trusted place, not scattered through the codebase.
- Not branding validated values — branded types are most powerful when the factory function validates the value: 'function email(s: string): Email { if (!isEmail(s)) throw new Error(); return s as Email; }'.
- Over-branding — branding every string is noisy; use it for IDs, validated values, and domain-specific types where confusion would cause real bugs.
Code Examples
✗ Vulnerable
// ❌ Structural typing lets IDs be swapped silently
type UserId = string;
type OrderId = string;
function getOrdersByUser(userId: UserId, orderId: OrderId): void {}
const uid: UserId = 'user-123';
const oid: OrderId = 'order-456';
getOrdersByUser(oid, uid); // No error — both are strings!
✓ Fixed
// ✅ Branded types — ID swap is a compile error
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
// Trusted factory functions — the only place the cast lives
const userId = (id: string): UserId => id as UserId;
const orderId = (id: string): OrderId => id as OrderId;
function getOrdersByUser(userId: UserId, orderId: OrderId): void {}
const uid = userId('user-123');
const oid = orderId('order-456');
getOrdersByUser(oid, uid); // TS Error: Argument of type 'OrderId' not assignable to 'UserId'
getOrdersByUser(uid, oid); // ✓ Correct
Tags
🤝 Adopt this term
£79/year · your link shown here
Added
23 Mar 2026
Views
26
🤖 AI Guestbook educational data only
|
|
Last 30 days
Agents 0
No pings yet today
No pings yesterday
Amazonbot 8
Perplexity 4
Google 2
Ahrefs 2
ChatGPT 1
Also referenced
How they use it
crawler 16
crawler_json 1
Related categories
⚡
DEV INTEL
Tools & Severity
⚙ Fix effort: Medium
⚡ Quick Fix
Create a branding helper: 'type Brand<T, B> = T & { readonly __brand: B }'. Then 'type UserId = Brand<string, 'UserId'>'. Create factory functions that do the single trusted cast from unbranded to branded values.