Branded / Nominal Types
Branded / Nominal Types
Bạn đang bảo trì một codebase lớn. Mọi thứ đều là string -- userId, email, token, slug. Rồi một ngày bạn truyền email vào chỗ cần userId. TypeScript không hề cảnh báo. Tests pass. Bug lên production.
Vấn đề gốc rễ: TypeScript dùng structural typing, nghĩa là hai kiểu có cùng cấu trúc thì coi là giống nhau. string là string, không quan tâm bạn dùng nó với mục đích gì.
Bài học này sẽ cho bạn kỹ thuật branded type -- cách "đóng dấu" lên primitive để compiler phân biệt UserId với Email, dù cả hai đều là string ở runtime.
#Structural Typing vs Nominal Typing
Hầu hết ngôn ngữ typed (Java, C#, Swift) dùng nominal typing -- hai kiểu chỉ bằng nhau khi có cùng tên. TypeScript thì khác:
// ❌ Structural typing: compiler chỉ nhìn vào "hình dạng"
interface Point {
x: number;
y: number;
}
interface Coordinate {
x: number;
y: number;
}
const p: Point = { x: 1, y: 2 };
const c: Coordinate = p; // ✅ Không lỗi! Cùng shape -> gán được
// Nếu đây là Java, đoạn code trên sẽ compile error
// vì Point và Coordinate là hai tên khác nhauTrong hầu hết trường hợp, đây là ưu điểm -- nó linh hoạt hơn. Nhưng khi bạn cần phân biệt hai kiểu có cùng "hình dạng" nhưng khác ý nghĩa, structural typing trở thành kẻ thù.
#Primitive Obsession -- Anti-pattern phổ biến
Primitive obsession là khi bạn dùng primitive types (string, number) ở mọi nơi thay vì tạo kiểu có ý nghĩa:
// ❌ Primitive obsession: tất cả đều là string
function getUser(userId: string): User { /* ... */ }
function sendEmail(email: string): void { /* ... */ }
function createToken(userId: string, ttl: number): string { /* ... */ }
// Không có gì ngăn bạn làm điều này:
const token = createToken("user@example.com", 3600);
// ^ email được truyền vào chỗ cần userId
// Compiler im lặng. Bug!Dấu hiệu bạn đang bị primitive obsession:
- Hàm nhận
stringhoặcnumbermà không rõ "loại" nào - Bạn comment
// userIdbên cạnh tham số để tự nhắc - Bug do truyền sai thứ tự arguments nhưng compiler không báo
#Branded Type -- Kỹ thuật cơ bản
Ý tưởng: thêm một property "vô hình" (chỉ tồn tại ở compile time) để phân biệt các kiểu. Property này dùng unique symbol -- mỗi symbol là duy nhất trong toàn bộ chương trình.
// Bước 1: Tạo symbol riêng cho mỗi "brand"
declare const UserIdBrand: unique symbol;
declare const EmailBrand: unique symbol;
// Bước 2: Tạo branded type bằng intersection
type UserId = string & { readonly [UserIdBrand]: typeof UserIdBrand };
type Email = string & { readonly [EmailBrand]: typeof EmailBrand };
// Bước 3: Constructor function để tạo giá trị
function createUserId(raw: string): UserId {
// Ở đây bạn có thể thêm validation
if (!raw.startsWith("usr_")) {
throw new Error(`Invalid UserId format: ${raw}`);
}
return raw as UserId;
}
function createEmail(raw: string): Email {
if (!raw.includes("@")) {
throw new Error(`Invalid email: ${raw}`);
}
return raw as Email;
}
// Sử dụng
const userId = createUserId("usr_abc123");
const email = createEmail("user@example.com");
function getProfile(id: UserId): void {
console.log(`Fetching profile for ${id}`);
}
getProfile(userId); // ✅ Hoạt động
getProfile(email); // ❌ Type error! Email không assignable tới UserId
getProfile("usr_abc123"); // ❌ Type error! string không assignable tới UserIdCơ chế hoạt động:
- Ở runtime, branded type vẫn là
stringbình thường. Không có overhead. - Ở compile time,
{ readonly [UserIdBrand]: typeof UserIdBrand }tạo ra một property "ảo" mà chỉ type mới có. Kiểustringgốc không có property này, nên compiler từ chối gán. unique symbolđảm bảo không có hai symbol nào trùng nhau, nênUserIdBrandkhácEmailBrand.
#Kiểm tra kiểu ở runtime
Branded type hoàn toàn biến mất ở runtime. Nếu bạn cần kiểm tra kiểu khi đang chạy, bạn cần thêm logic:
// Type guard để kiểm tra runtime
function isUserId(value: unknown): value is UserId {
return typeof value === "string" && value.startsWith("usr_");
}
function isEmail(value: unknown): value is Email {
return typeof value === "string" && value.includes("@");
}
// Dùng trong runtime validation
function handleInput(input: unknown) {
if (isUserId(input)) {
getProfile(input); // ✅ TypeScript biết đây là UserId
} else if (isEmail(input)) {
sendWelcomeEmail(input); // ✅ TypeScript biết đây là Email
}
}#Zod-style Branded Type
Zod -- thư viện validation phổ biến nhất -- có built-in support cho branded type. Đây là cách nó hoạt động bên trong:
// Thuật ngữ: Zod dùng .brand() thay vì intersection thủ công
import { z } from "zod";
const UserId = z.string().brand<"UserId">();
const Email = z.string().email().brand<"Email">();
type UserId = z.infer<typeof UserId>; // string & { readonly __brand: "UserId" }
type Email = z.infer<typeof Email>; // string & { readonly __brand: "Email" }
// Zod tự động validate + brand trong một bước
const userId = UserId.parse("usr_abc123"); // ✅ Branded UserId
const bad = UserId.parse("not-a-user-id"); // ❌ Throws ZodError
// Không thể nhầm lẫn
function getProfile(id: UserId): void { /* ... */ }
const email = Email.parse("hi@example.com");
getProfile(email); // ❌ Type errorBạn cũng có thể tự xây dựng utility tương tự:
// Generic brand utility
declare const __brand: unique symbol;
type Brand<B extends string> = { readonly [__brand]: B };
type Branded<T, B extends string> = T & Brand<B>;
// Tạo các branded type một cách nhất quán
type UserId = Branded<string, "UserId">;
type Email = Branded<string, "Email">;
type PositiveNumber = Branded<number, "PositiveNumber">;
type NonEmptyString = Branded<string, "NonEmptyString">;
// Constructor generic
function brand<T, B extends string>(
value: T,
validate: (v: T) => boolean,
errorMessage: string
): Branded<T, B> {
if (!validate(value)) {
throw new Error(errorMessage);
}
return value as Branded<T, B>;
}#Branded Type cho Non-Primitive
Branding không chỉ dùng cho string và number. Bạn có thể brand bất kỳ kiểu nào:
declare const ValidatedUserBrand: unique symbol;
declare const RawUserBrand: unique symbol;
type ValidatedUser = User & { readonly [ValidatedUserBrand]: true };
type RawUser = User & { readonly [RawUserBrand]: true };
// Sau khi validate, "nâng cấp" từ RawUser -> ValidatedUser
function validateUser(raw: RawUser): ValidatedUser {
if (!raw.email || !raw.name) {
throw new Error("Invalid user data");
}
return raw as ValidatedUser;
}
// Các hàm downstream chỉ nhận ValidatedUser
function saveToDatabase(user: ValidatedUser): void { /* ... */ }
function sendEmail(user: ValidatedUser): void { /* ... */ }
// Không thể bypass validation
const raw: RawUser = fetchDataFromAPI();
saveToDatabase(raw); // ❌ Type error! Phải validate trước
saveToDatabase(validateUser(raw)); // ✅Pattern này gọi là "make illegal states unrepresentable" -- bạn dùng type system để ép buộc business flow.
#Anti-pattern: Cast không kiểm tra
Sai lầm phổ biến nhất khi dùng branded type là cast bừa bãi:
type UserId = Branded<string, "UserId">;
// ❌ ANTI-PATTERN: Cast trực tiếp, bỏ qua validation
const input = getQueryParams("userId");
const userId = input as UserId; // "Trust me bro"
// Hậu quả: userId có thể là "", undefined đã bị cast, hoặc SQL injection
// Branded type trở thành giấy phép giả -- có vẻ an toàn nhưng thực tế không
// ✅ ĐÚNG: Luôn validate trước khi brand
function parseUserId(raw: string): UserId {
if (!raw.match(/^usr_[a-zA-Z0-9]+$/)) {
throw new Error(`Invalid UserId: "${raw}"`);
}
return raw as UserId;
}
// Hoặc dùng kiểu "parse, don't validate" pattern
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
function tryParseUserId(raw: string): Result<UserId> {
if (!raw.match(/^usr_[a-zA-Z0-9]+$/)) {
return { ok: false, error: `Invalid UserId format: "${raw}"` };
}
return { ok: true, value: raw as UserId };
}Quy tắc vàng: Nếu bạn thấy as SomeBrand mà không có validation ngay phía trước, đó là code smell.
#Branded Type trong thực tế
Khi nào nên dùng branded type:
| Tình huống | Có nên brand? |
|---|---|
| UserId, Email, Token -- dễ nhầm lẫn | ✅ Có |
Hàm nhận string mà bạn phải comment giải thích | ✅ Có |
| Đơn vị tiền tệ (VND vs USD) | ✅ Có |
| Kiểm soát flow: RawData vs ValidatedData | ✅ Có |
| String thông thường, không có "ý nghĩa" đặc biệt | ❌ Không |
| Over-engineering: brand mọi thứ trong codebase nhỏ | ❌ Không |
#Tổng kết
- Structural typing là mặc định của TS -- hai kiểu cùng shape thì gán được cho nhau. Điều này tiện nhưng không phân biệt được
UserIdvàEmailkhi cả hai đều làstring. - Primitive obsession là anti-pattern: dùng
string/numbereverywhere thay vì tạo kiểu có ý nghĩa. Dẫn đến bug khó phát hiện. - Branded type dùng
unique symbol+ intersection type để "đóng dấu" lên primitive. Ở runtime không có overhead -- branded type vẫn là giá trị gốc. - Luôn validate trước khi brand. Cast
as Brandmà không validate là anti-pattern nguy hiểm -- nó tạo cảm giác an toàn giả. - Branded type rất tốt cho domain modeling: phân biệt units, enforce business flow, và "make illegal states unrepresentable".
- Không nên over-engineer: chỉ brand những kiểu có nguy cơ nhầm lẫn cao.
Bài tiếp theo chúng ta sẽ đi vào Recursive Types -- cách định nghĩa kiểu đệ quy như JSON type, DeepReadonly, và giới hạn của compiler khi recursion quá sâu.