TypeScript uses structural typing: two types are compatible if they have the same shape. This means type UserId = string and type PostId = string are completely interchangeable — the compiler will not catch a PostId being passed where a UserId is expected.
TypeScript sử dụng hệ thống kiểu cấu trúc (structural typing): hai kiểu tương đương nhau nếu chúng có cùng hình dạng. Điều này có nghĩa là type UserId = string và type PostId = string hoàn toàn có thể hoán đổi cho nhau — trình biên dịch sẽ không bắt lỗi khi bạn truyền PostId vào chỗ cần UserId.
1type UserId = string;2type PostId = string;34function getUser(id: UserId): void {5 console.log("fetching user", id);6}78const postId: PostId = "post-42";9getUser(postId); // No error! This is a bug waiting to happen.A plain type alias does not create a new type — it is just another name for the same underlying type. To create real type-level distinctness with zero runtime cost, we need branded types.
Type alias thông thường không tạo ra kiểu mới — chúng chỉ là tên khác cho cùng một kiểu. Để tạo ra sự khác biệt thực sự ở cấp độ kiểu mà không tốn chi phí runtime, chúng ta cần branded types.
The core idea is to intersect the base type with a "brand" property that never actually exists at runtime. Because each brand is unique to its type, TypeScript treats them as incompatible.
Ý tưởng cốt lõi là giao (intersect) kiểu cơ sở với một thuộc tính 'nhãn hiệu' (brand) không bao giờ thực sự tồn tại ở runtime. Vì nhãn hiệu này là duy nhất cho mỗi kiểu, TypeScript coi chúng là không tương thích nhau.
1// Using a unique symbol as the brand — each unique symbol is truly distinct2declare const __brand: unique symbol;3type Brand<T, B> = T & { readonly [__brand]: B };45// Alternatively, a simpler string-based brand (less strict but readable)6type UserId = string & { readonly __brand: "UserId" };7type PostId = string & { readonly __brand: "PostId" };89// Now they are incompatible:10declare const userId: UserId;11declare const postId: PostId;1213function getUser(id: UserId): void { /* ... */ }1415getUser(userId); // OK16getUser(postId); // Error: Argument of type 'PostId' is not assignable to parameter of type 'UserId'17getUser("abc"); // Error: plain string is not assignable to UserIdUserId is still a plain string — no __brand property is added to the object. The runtime cost is zero.Thuộc tính brand chỉ tồn tại ở cấp độ kiểu. Ở runtime, một UserId vẫn là một string thông thường — không có thuộc tính __brand nào được thêm vào đối tượng. Chi phí runtime là bằng không.
Because you cannot assign a raw string to UserId, you need a smart constructor — a function that performs validation and then uses a type assertion to return the branded type. This is the key insight: every branded value must pass through the validator.
Vì bạn không thể gán string thô vào UserId, bạn cần một 'smart constructor' — một hàm thực hiện validation rồi dùng type assertion để trả về branded type. Đây là điểm mấu chốt: mọi giá trị branded đều phải đi qua validator.
1type UserId = string & { readonly __brand: "UserId" };2type Email = string & { readonly __brand: "Email" };3type PositiveInt = number & { readonly __brand: "PositiveInt" };45// Smart constructor for UserId — validates UUID format6function UserId(raw: string): UserId {7 if (!/^[0-9a-f-]{36}$/.test(raw)) {8 throw new Error(`Invalid UserId: ${raw}`);9 }10 return raw as UserId; // The only place we use 'as' — gated by validation11}12You can also use a Result pattern to return errors instead of throwing, or combine branding with a library like Zod for both validation and branding in one step.
Bạn cũng có thể dùng Result pattern để trả về lỗi thay vì throw, hoặc dùng các thư viện như zod để kết hợp validation và branding cùng lúc.
Branded types are especially valuable for units of measure and currency, where confusing units can cause catastrophic bugs (famously, the 1999 Mars Climate Orbiter crash).
Branded types đặc biệt hữu ích cho các đơn vị đo lường và tiền tệ, nơi mà việc nhầm lẫn đơn vị có thể gây ra lỗi nghiêm trọng (như dự án Mars Climate Orbiter năm 1999).
1// Currency units — Cents is the canonical internal representation2type Cents = number & { readonly __brand: "Cents" };3type Dollars = number & { readonly __brand: "Dollars" };45function Cents(value: number): Cents {6 if (!Number.isInteger(value) || value < 0) throw new Error("Cents must be a non-negative integer");7 return value as Cents;8}910function Dollars(value: number): Dollars {11 if (value < 0) throw new Error("Dollars must be non-negative");12 return value as Dollars;Rather than repeating the intersection pattern for every branded type, extract it into a reusable Brand<T, B> helper. This is how most large codebases implement branded types.
Thay vì lặp lại pattern giao kiểu cho mỗi branded type, bạn có thể tạo một helper type Brand<T, B> dùng chung. Đây là cách hầu hết các codebase lớn triển khai branded types.
1// Generic Brand helper2type Brand<T, B extends string> = T & { readonly __brand: B };34// Define all branded types using the helper5type UserId = Brand<string, "UserId">;6type PostId = Brand<string, "PostId">;7type Email = Brand<string, "Email">;8type Cents = Brand<number, "Cents">;9type Dollars = Brand<number, "Dollars">;10type Latitude = Brand<number, "Latitude">;11type Longitude = Brand<number, "Longitude">;12TypeScript là structural — type alias thông thường không tạo ra kiểu không tương thích
T & { readonly __brand: B }Brand pattern giao kiểu cơ sở với thuộc tính nhãn duy nhất: T & { readonly __brand: B }
Thuộc tính brand chỉ tồn tại ở compile-time — chi phí runtime là bằng không
Smart constructors là điểm kiểm soát duy nhất, dùng type assertion sau khi validate
Brand<T, B> helper to avoid repeating the intersection patternDùng helper Brand<T, B> để tránh lặp lại pattern intersection
Các use case điển hình: IDs, validated strings, đơn vị đo, tiền tệ, input đã được sanitize