TypeScript Branded Types — Complete Guide
In this tutorial, you will learn about TypeScript Branded Types. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript branded types simulate nominal typing in a structural type system — they add a phantom property that distinguishes structurally identical types at compile time without any runtime overhead, preventing accidental mixing of IDs, currencies, and units.
What You'll Learn
- Branded type pattern with intersection types
- The
unique symbolapproach - Currency and unit safety
- ID type safety with branded strings
- When to brand vs use plain types
Why It Matters
In a large codebase, mixing up a userId with a scanId is a real source of bugs. Both are strings, but they represent different concepts. Branded types make these swaps a compile-time error without any runtime cost.
Real-World Use
Durga Antivirus Pro uses UserId, ScanId, and ThreatId branded types throughout its codebase. A function expecting a ScanId cannot accidentally receive a UserId, even though both are strings. This has prevented dozens of bugs in production.
Learning Path
flowchart LR A[Overloads] --> B[Branded Types] B --> C[tsconfig Deep Dive] B --> D[You Are Here] C --> E[Project References] E --> F[Module Resolution]
Basic Brand Pattern
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { [brand]: B };
type UserId = Brand<string, "UserId">;
type ScanId = Brand<string, "ScanId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId { return id as UserId; }
function createScanId(id: string): ScanId { return id as ScanId; }
function getUser(id: UserId): void { console.log(`User: ${id}`); }
function getScan(id: ScanId): void { console.log(`Scan: ${id}`); }
const uid = createUserId("usr-001");
const sid = createScanId("scan-001");
getUser(uid); // OK
// getUser(sid); // Error: ScanId is not UserId
Think of brands as color-coded labels — two white T-shirts look identical, but one has a blue tag (UserId) and another a red tag (ScanId). The tags are invisible to anyone not looking for them (at runtime), but the sorting machine (the compiler) can distinguish them perfectly.
Currency Safety
type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;
type GBP = Brand<number, "GBP">;
function usd(amount: number): USD { return amount as USD; }
function eur(amount: number): EUR { return amount as EUR; }
function gbp(amount: number): GBP { return amount as GBP; }
function addUSD(a: USD, b: USD): USD { return (a + b) as USD; }
function convertToUSD(eur: EUR, rate: number): USD { return (eur * rate) as USD; }
const price = usd(29.99);
const tax = usd(3.00);
const total = addUSD(price, tax); // OK
const euroCost = eur(25.00);
// addUSD(price, euroCost); // Error: EUR is not USD
const converted = convertToUSD(euroCost, 1.08);
console.log(`Total: $${total + converted}`); // type: number (brand lost after arithmetic)
Unit Safety
type Meters = Brand<number, "Meters">;
type Feet = Brand<number, "Feet">;
type Seconds = Brand<number, "Seconds">;
function meters(v: number): Meters { return v as Meters; }
function feet(v: number): Feet { return v as Feet; }
function toFeet(m: Meters): Feet { return (m * 3.28084) as Feet; }
const roomWidth = meters(10);
const roomHeight = feet(32.8);
// toFeet(roomHeight); // Error: Feet is not Meters
console.log(`Width in feet: ${toFeet(roomWidth)}`);
ID Type Safety
type ArticleId = Brand<string, "ArticleId">;
type CommentId = Brand<string, "CommentId">;
declare function getArticle(id: ArticleId): Promise<Article>;
declare function getComment(id: CommentId): Promise<Comment>;
const articleId = "art-123" as ArticleId;
const commentId = "cmt-456" as CommentId;
getArticle(articleId); // OK
// getArticle(commentId); // Error
// Cannot accidentally swap in API calls
function deleteResource(id: ArticleId | CommentId): void {
// ...
}
Advanced: Nominal Classes
class Nominal<Tag extends string> {
private readonly __tag!: Tag;
}
type Meters = Nominal<"Meters"> & { value: number };
type Kilograms = Nominal<"Kilograms"> & { value: number };
function calculateForce(mass: Kilograms, acceleration: Meters): number {
return mass.value * acceleration.value;
}
const m = { value: 5 } as Meters;
const kg = { value: 10 } as Kilograms;
calculateForce(kg, m); // OK
// calculateForce(m, kg); // Error
Common Mistakes
1. Brands Lost After Operations
type USD = Brand<number, "USD">;
const a = 100 as USD;
const b = a * 2; // type: number — brand lost!
const c = (a * 2) as USD; // Must re-brand
2. Runtime Brand Access
Brands are compile-time only. brand symbol does not exist at runtime.
3. Forgetting as Casts in Factory Functions
Always provide factory functions to create branded values safely.
4. Over-Branding Simple Values
Not every value needs a brand. Use brands only when mixing up types would cause bugs.
5. Brand Collisions
Use unique symbol to prevent brand collisions across modules.
Practice Questions
What is the purpose of branded types? To distinguish structurally identical types at compile time, preventing accidental mixing.
What is
unique symbolused for? To ensure the brand property is unique across the entire codebase, preventing collision.Do brands exist at runtime? No. Brands are erased during compilation and have zero runtime cost.
When should you NOT use branded types? When the type distinction is already enforced by the type system (e.g., different interfaces with different properties).
Challenge: Create branded types for a cooking recipe system with Teaspoon, Tablespoon, and Cup. Write functions to add same-unit measurements and convert between units with compile-time safety.
FAQ
Mini Project: Type-Safe Shopping Cart
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { [brand]: B };
type USD = Brand<number, "USD">;
type ItemId = Brand<string, "ItemId">;
type CartId = Brand<string, "CartId">;
function usd(v: number): USD { return v as USD; }
function itemId(v: string): ItemId { return v as ItemId; }
interface CartItem {
id: ItemId;
name: string;
price: USD;
quantity: number;
}
class ShoppingCart {
private items: CartItem[] = [];
constructor(public readonly id: CartId) {}
addItem(item: CartItem): void { this.items.push(item); }
getTotal(): USD {
const total = this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
return total as USD;
}
getItemCount(): number {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
}
const cart = new ShoppingCart("cart-abc" as CartId);
cart.addItem({ id: itemId("item-1"), name: "Scanner License", price: usd(49.99), quantity: 1 });
cart.addItem({ id: itemId("item-2"), name: "VPN Subscription", price: usd(29.99), quantity: 2 });
console.log(`Cart total: $${cart.getTotal()}`); // $109.97
console.log(`Items: ${cart.getItemCount()}`); // 3
What's Next
You've completed Module 4: Type System Deep Dive. Now explore Tooling & Config:
| Lesson | Description |
|---|---|
| {{< ref "/programming-languages/typescript/29-overloads-hybrid" >}} | Review overloads |
| {{< ref "/programming-languages/typescript/31-tsconfig-deep-dive" >}} | strict, noImplicitAny, target, module |
| {{< ref "/programming-languages/typescript/32-project-references" >}} | Composite projects and build mode |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro