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; }'.
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.
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