Branded / Opaque Types in TypeScript
debt(d1/e3/b3/t5)
Closest to 'caught instantly (compiler/syntax error)' (d1). The entire purpose of branded types is to make misuse a compile-time error — TypeScript's type checker rejects a UserId where an OrderId is expected immediately, with no extra tooling required.
Closest to 'simple parameterised fix (replace pattern with safer alternative)' (e3). The quick_fix describes creating a reusable Brand<T,B> helper type and factory functions. This is a small, contained refactor: define the helper, define the branded type aliases, and write factory functions — all within a type definitions file or domain module, not a cross-cutting change.
Closest to 'localised tax (one component pays, rest of codebase unaffected)' (b3). Branded types are opt-in and scoped to the domain types where they are applied. Files that use branded types must go through factory functions, but the pattern doesn't impose structural constraints on the rest of the codebase. Over-branding (a noted common mistake) could raise this, but typical disciplined use stays localised.
Closest to 'notable trap (a documented gotcha most devs eventually learn)' (t5). The canonical misconception is that branded types add runtime overhead, but they are fully erased at compile time. Additionally, a common mistake is using string literals instead of unique symbols as brands, which causes structural compatibility between different branded types — a subtle gotcha that undermines the whole point of branding and is non-obvious until encountered.
Also Known As
TL;DR
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; }'.
Watch Out
Common Misconception
Why It Matters
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.
Avoid When
- When you need runtime type distinctions — branded types are erased at runtime, so they cannot guard against invalid data at API boundaries or after deserialization.
- When the cost of boilerplate factory functions outweighs the risk — for internal utility types with limited scope and low risk of confusion, the overhead of branded types may be unnecessary.
- When working with libraries or frameworks that don't cooperate with branded types — some tools expect plain types and will lose or misinterpret the phantom brand property through transformations.
- When you need to interchange related branded types flexibly — if a UserId must sometimes be treated as a GenericId, excessive branding creates friction that requires repeated type assertions or union types.
When To Use
- You have multiple ID or identifier types (UserId, OrderId, ProductId) that are all strings or numbers at runtime but must never be confused — branding prevents silent bugs from passing the wrong ID to a function.
- Your codebase frequently passes primitive values (strings, numbers) between functions and you want to catch type mismatches at compile time rather than discovering them in production.
- You need to enforce that a value has been validated or transformed before use — a branded type signals that a raw string has passed through a trusted parsing or sanitization function.
- You're building a domain model or API layer where semantic correctness matters more than raw type compatibility, and the cost of a wrong ID swap is high.
Code Examples
// ❌ 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!
// ✅ 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