Menu

Branded type trong TypeScript: nominal typing qua ví dụ

Branded type là một kiểu nguyên thủy mang một thẻ vô hình, như string & { readonly __brand: "UserId" }, để một UserId không thể được truyền vào chỗ cần OrderId. Tìm hiểu cách brand hoạt động, hàm khởi tạo có kiểm tra dữ liệu, helper Brand dạng generic, brand bằng unique symbol và số có brand.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

TypeScript so sánh kiểu theo cấu trúc, nên hai alias của string có thể thay thế cho nhau. Branded type thêm một thẻ chỉ tồn tại trong hệ thống kiểu, string & { readonly __brand: "UserId" }, khiến một UserId không tương thích với chuỗi thông thường và với mọi brand khác:

Lúc runtime, userId chỉ là chuỗi "u_42". Brand là một nhãn lúc biên dịch, và nhiệm vụ duy nhất của nó là ngăn việc nhầm lẫn ID, đơn vị đo và các chuỗi đã được kiểm tra.

Vấn đề: alias chỉ là tên gọi

Một type alias không tạo ra kiểu mới. Nó đặt thêm một tên cho kiểu có sẵn, và trình biên dịch coi cả hai tên là một:

Đây là structural typing: TypeScript kiểm tra cấu trúc có khớp không, và string khớp với string. Với đối tượng, cấu trúc thường khác nhau; với ID, email, tiền tệ và đơn vị đo thì không bao giờ khác. Brand giải quyết đúng trường hợp đó.

Brand hoạt động thế nào

string & { readonly __brand: "UserId" } là một intersection: giá trị phải là chuỗi và đồng thời có thuộc tính __brand kiểu "UserId". Không chuỗi thật nào có thuộc tính đó, nên không chuỗi thông thường nào gán được cho kiểu này:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

Chiều quan trọng vẫn hoạt động: một UserId là một chuỗi, nên bạn có thể truyền nó cho bất cứ thứ gì nhận chuỗi, gọi .startsWith() trên nó, hoặc đặt nó vào template. Brand chỉ chặn chiều đi vào.

Hàm khởi tạo có kiểm tra dữ liệu

Assertion as UserId là lối vào duy nhất, và assertion thì không kiểm tra gì. Hãy đặt nó trong một hàm kiểm tra đầu vào, và khi đó mọi giá trị có brand trong chương trình đều chắc chắn đã qua phép kiểm tra ấy:

sendWelcome không bao giờ phải kiểm tra lại đầu vào, vì kiểu của tham số đã nói rằng phép kiểm tra đã diễn ra. Đây là ý tưởng "parse, don't validate": kiểm tra ở ranh giới, rồi mang bằng chứng đó trong kiểu. Một type guard cũng dùng được như hàm khởi tạo khi bạn thích trả về boolean hơn là ném ngoại lệ: function isEmail(s: string): s is Email.

Helper Brand dạng generic

Viết tay intersection cho từng kiểu sẽ lặp đi lặp lại. Một generic nhỏ làm việc đó một lần:

Gắn brand cho số hoạt động giống như gắn brand cho chuỗi. Lưu ý amount / 100 là một number thông thường: phép tính trên số có brand cho ra kết quả không có brand, sẽ nói ở bên dưới.

Brand bằng unique symbol

Tên thuộc tính dạng chuỗi như __brand trông như một thuộc tính thật: userId.__brand qua được kiểm tra kiểu với kiểu "UserId" nhưng lại là undefined lúc runtime, và hai thư viện có thể chọn trùng tên. Khóa unique symbol tránh được cả hai vấn đề:

declare const brand: unique symbol khai báo một symbol chỉ tồn tại cho bộ kiểm tra kiểu; từ khóa declare nghĩa là không có JavaScript nào được sinh ra cho nó. Vì symbol không được export khỏi module của nó, code ở tệp khác thậm chí không gọi tên được thuộc tính brand, nên bên ngoài module đó, cách duy nhất để có một Meters là qua các hàm bạn export hoặc một assertion as Meters.

Brand không tốn gì lúc runtime

Đầu ra sau khi biên dịch không còn dấu vết nào của brand. Đây là các dòng được sinh ra cho ví dụ Meters, bên dưới phần header module mà trình biên dịch thêm vào: declare, cả hai type alias và mọi as đều biến mất, còn lời gọi bị chặn lỗi ở dòng cuối vẫn chạy.

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

Một giá trị có brand chính là kiểu nguyên thủy bình thường: typeof cho "string" hoặc "number", JSON.stringify ghi nó như thường, và các phép so sánh vẫn chạy như trước. Mặt trái là không có gì được kiểm tra lúc runtime trừ khi hàm khởi tạo của bạn kiểm tra. Dữ liệu parse từ JSON, cơ sở dữ liệu hay URL đến dưới dạng string, và chỉ trở thành UserId khi bạn đưa nó qua hàm đó.

Phép tính và phương thức làm mất brand

Các thao tác trên giá trị có brand trả về kiểu gốc, vì brand không thuộc về kết quả mà + hay .slice() tạo ra:

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

Thường đó chính là điều bạn muốn: cộng hai số tiền tính bằng cent cho ra cent, nhưng nhân cent với cent thì không, và chỉ bạn mới biết thao tác nào giữ nguyên ý nghĩa. Hãy viết các helper nhỏ như addCents(a: Cents, b: Cents): Cents cho những thao tác mà code của bạn cần.

Khi nào dùng branded type

Dùng brand ở nơi việc nhầm lẫn hai giá trị cùng kiểu nguyên thủy là rủi ro thật và trình biên dịch không giúp được bằng cách khác:

Tình huốngVí dụ brand
ID từ các bảng khác nhauUserId, OrderId, ProductId
Chuỗi đã được kiểm traEmail, Url, NonEmptyString, Slug
Đơn vị đo và tiền tệMeters, Feet, Cents, Usd, Eur
Văn bản đã được làm sạch hoặc escapeSafeHtml, SqlIdentifier
Số trong một khoảng giá trịPercentage, PositiveInt

Bỏ qua chúng với những giá trị không bao giờ bị nhầm, và với các object type vốn đã khác nhau về cấu trúc. Các thư viện kiểm tra dữ liệu có thể tạo branded type từ một schema: trong Zod, z.string().brand<"UserId">() cho một schema mà parse trả về UserId có brand, giúp bạn khỏi phải tự viết hàm khởi tạo.

Câu hỏi thường gặp

Branded type trong TypeScript là gì?

Là một mẫu giúp hai kiểu có cùng cách biểu diễn lúc runtime trở nên không tương thích với nhau. Bạn lấy giao (intersection) của kiểu gốc với một thẻ mà không giá trị thông thường nào có: type UserId = string & { readonly __brand: "UserId" }. Khi đó một chuỗi thông thường, hay một OrderId có thẻ khác, sẽ bị từ chối ở chỗ cần UserId.

TypeScript có nominal type không?

Không. Hệ thống kiểu của TypeScript là structural: hai kiểu có cùng cấu trúc thì thay thế được cho nhau, bất kể tên gọi. Khai báo lớp có thành viên private hoặc #private hoạt động theo kiểu nominal, và branded type là cách phổ biến để có hiệu ứng tương tự cho các kiểu nguyên thủy như chuỗi và số.

Branded type có tốn chi phí lúc runtime không?

Không. Brand chỉ tồn tại trong kiểu. Lúc runtime, giá trị vẫn là một chuỗi hoặc số bình thường, không có thuộc tính nào thêm, và JavaScript sau khi biên dịch giống hệt như khi không có brand. Code runtime duy nhất là phần kiểm tra dữ liệu mà bạn chọn đặt trong hàm tạo ra giá trị có brand.

Làm sao tạo một giá trị có branded type?

Bằng một type assertion, tốt nhất là đặt trong một hàm nhỏ kiểm tra đầu vào trước: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Giữ as ở đúng một chỗ đó nghĩa là mọi Email trong chương trình đều đã qua phép kiểm tra.

Type alias và branded type khác nhau thế nào?

type UserId = string chỉ là một tên mới: mọi chuỗi đều được chấp nhận ở chỗ cần UserId. type UserId = string & { readonly __brand: "UserId" } là một kiểu mới, không tương thích: một chuỗi thông thường phải đi qua hàm khởi tạo hoặc một assertion trước.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu