Có một loại bug mà test không bắt, review không thấy, compiler cũng im lặng: truyền sai tham số nhưng đúng kiểu. Mình từng chứng kiến một API hoàn tiền nhận userId, hôm đó có người truyền orderId vào. Code chạy trơn tru, unit test xanh hết, tới production mới lòi ra là tiền hoàn cho nhầm người. Cả hai tham số đều là string.
TypeScript nhìn thấy gì
Với compiler, string là string. Structural typing chỉ quan tâm hình dạng, mà type UserId = string và type OrderId = string có cùng một hình dạng. Không có lý do gì để nó phàn nàn.
Vấn đề không nằm ở chỗ TypeScript yếu. Vấn đề là type UserId = string sinh ra để đặt tên, không phải để phân biệt. Muốn compiler phân biệt thì hai kiểu phải khác nhau thật sự về cấu trúc — dù lúc chạy chúng chẳng khác gì nhau.
Branded type — dán nhãn lên kiểu
Cách gọn nhất là giao (intersection) kiểu gốc với một trường "chỉ tồn tại trên giấy".
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Vnd = Brand<number, 'VND'>;
type Usd = Brand<number, 'USD'>;
Lúc chạy, UserId vẫn chỉ là một chuỗi. Nhưng với compiler, nó là một kiểu riêng. Gán qua lại giữa UserId và OrderId báo lỗi ngay tại dòng code, chứ không phải trong log lúc 2 giờ sáng.
Hàng rào phải nằm ở biên
Nếu ai cũng as UserId cho nhanh thì brand vô nghĩa. Đúng bài là chỉ cho phép tạo giá trị có nhãn ở một chỗ duy nhất: nơi dữ liệu thật sự đi vào hệ thống — parse request body, đọc DB, đọc biến môi trường. Ở đó validate xong mới đóng dấu.
import { z } from 'zod';
const UserIdSchema = z.string().uuid().brand<'UserId'>();
const OrderIdSchema = z.string().uuid().brand<'OrderId'>();
type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;
// cửa duy nhất để có UserId hợp lệ
const toUserId = (raw: unknown): UserId => UserIdSchema.parse(raw);
async function refund(orderId: OrderId, amount: Vnd): Promise<void> {
// ...
}
const userId = toUserId(req.body.userId);
await refund(userId, 50_000 as Vnd);
// ^^^^^^ TS2345: Argument of type 'UserId' is not
// assignable to parameter of type 'OrderId'
Lỗi hiện ra trước khi commit, không phải sau khi khách thanh toán. Từ dòng đó trở đi, mọi hàm bên trong chỉ nhận OrderId thật, và không có chỗ nào lách qua được ngoài việc sửa hàm parse — đúng cái mình muốn người sửa phải xem.
Chỗ brand ăn tiền nhất: tiền và đơn vị
ID chỉ là ví dụ dễ thấy. Mình thấy brand đáng giá hơn ở hai chỗ khác.
- Đơn vị thời gian.
MsvớiSecondsđều là number. Mình từng set timeout30tưởng là 30 giây, mà API nhận mili-giây. Job chết sau 30ms và log không nói gì về đơn vị. - Tiền tệ. Cộng hai số
numbervới nhau thì compiler không biết đó là VND hay USD. Với brand, cộng sai loại tiền là lỗi compile.
Ngoài ra còn SafeHtml sau khi sanitize, Email đã validate, HashedPassword. Nguyên tắc chung: brand cho những thứ trông giống nhau nhưng ý nghĩa khác nhau. Hai thứ luôn đi cùng nhau và không bao giờ bị trộn thì brand chỉ thêm gánh nặng.
Cái giá phải trả
Nói thẳng, nó không miễn phí:
- Ergonomic tệ hơn. Mỗi chuỗi từ bên ngoài vào là một lần parse. Test cũng phải tạo giá trị qua helper thay vì gõ literal cho nhanh.
- JSON và DB không giữ nhãn. Dữ liệu trả về là
stringtrơn, nên chỗ parse lại phải làm đúng. Làm nửa vời thì hàng rào chỉ có ở nửa đường. - Không thay thế validation. Brand là tờ giấy chứng nhận đã kiểm, không phải cái máy kiểm. Cast ẩu một lần là hỏng cả hệ thống niềm tin.
- Dễ lạm dụng. Brand mọi string làm chữ ký hàm đọc như mật mã, và dev mới vào sẽ chỉ muốn
ascho xong.
Mình chọn brand ở đúng vài biên nguy hiểm — tiền, ID, đơn vị thời gian, HTML — còn lại cứ để string cho dễ sống.
Đáng nói thêm: ở Go hay Rust chuyện này có sẵn. type UserId string trong Go là một kiểu mới thật sự, muốn gán từ string phải ép tường minh. TypeScript chọn structural typing nên phải "giả" bằng brand — đổi lại thì hàng rào nhẹ và không tốn gì lúc chạy.
Kết
Branded type không làm code chạy nhanh hơn, cũng không tự nhiên bắt được bug. Nó chỉ dời một nhóm bug từ production về trình biên dịch — chỗ mà sửa rẻ nhất. Một khi đã dùng, bạn sẽ thấy yên tâm hơn ở những hàm có hai tham số cùng kiểu.
Bạn có đang truyền hai tham số string cạnh nhau ở đâu không? Đó thường là ứng viên đầu tiên.
📋 Phụ lục thuật ngữ
- Structural typing — hệ thống kiểu so khớp theo hình dạng, không theo tên. Hai kiểu cùng cấu trúc thì coi như tương thích.
- Nominal typing — so khớp theo tên/định danh: cùng cấu trúc nhưng khác tên vẫn là hai kiểu khác nhau.
- Branded type — kiểu gốc được gắn thêm một nhãn chỉ tồn tại ở tầng kiểu, để compiler phân biệt nó với kiểu gốc.
- Intersection type — phép giao
A & B: giá trị phải thoả cả hai kiểu cùng lúc. - Biên (boundary) — nơi dữ liệu chưa được kiểm đi vào hệ thống: request body, response API, biến môi trường, dữ liệu từ DB.
.brand()trong Zod — API gắn nhãn của Zod, biến schema sau khi validate thành nguồn duy nhất sinh ra kiểu đã có nhãn.