TypeScript 品牌类型是什么?如何避免相同基础类型混用?
简化版
品牌类型是在基础类型上叠加一个只用于类型区分的标记,让同样都是 string 或 number 的值不能随便混用。
它常用于用户 ID、订单 ID、金额分、邮箱、URL 等业务语义不同但底层类型相同的值。品牌类型主要提供编译期保护,运行时仍然是原来的值。
详细版
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function getUser(id: UserId) {}
const orderId = "o1" as OrderId;
getUser(orderId); // 类型错误
通常配合构造函数或校验函数生成品牌值:
function createUserId(value: string): UserId {
if (!value.startsWith("u_")) throw new Error("invalid user id");
return value as UserId;
}
不要到处 as UserId,否则品牌类型会失去保护意义。
完整版教学
一、为什么结构化类型需要品牌
TypeScript 是结构化类型系统,只要结构相同就兼容。
type UserId = string;
type OrderId = string;
这两个类型本质上都是 string,不能阻止混用。
品牌类型是在结构化类型系统里人为加入“名义类型”的味道。
二、基础写法
type Brand<T, Name extends string> = T & {
readonly __brand: Name;
};
然后定义:
type UserId = Brand<string, "UserId">;
type ProductId = Brand<string, "ProductId">;
| 类型 | 运行时值 | 编译期语义 |
|---|---|---|
string | 字符串 | 普通文本 |
UserId | 字符串 | 用户 ID |
ProductId | 字符串 | 商品 ID |
三、品牌字段通常不真实存在
品牌字段只是类型层标记。
const id = "u_1" as UserId;
console.log(id); // "u_1"
运行时没有额外对象包装,也没有 __brand 字段。
这意味着品牌类型不能替代运行时校验。
四、用工厂函数集中创建
function toUserId(value: string): UserId {
if (!/^u_\d+$/.test(value)) {
throw new Error("invalid user id");
}
return value as UserId;
}
集中创建有两个好处:
- 校验逻辑统一
as被限制在边界位置
五、适合哪些业务值
常见场景:
UserId和OrderIdCent和普通 numberEmail和普通 stringSafeHtml和普通 stringAbsoluteUrl和普通 string
type Cent = Brand<number, "Cent">;
数字例子:100 可以表示 100 元、100 分、100 个库存,品牌类型能让含义更明确。
六、品牌类型的边界
品牌类型是编译期约束,不会阻止外部 JSON 传错值。
从接口、localStorage、URL 参数拿到的数据仍然要校验。
const raw = JSON.parse(text) as unknown;
不要直接把未知数据断言成品牌类型,应通过解析函数转换。
七、常见误区与追问
- 误区:品牌类型会改变运行时数据结构。 它通常只存在于编译期。
- 误区:到处
as Brand也很安全。 随意断言会绕过校验,品牌失去意义。 - 误区:品牌类型只适合字符串。 number、对象引用等也可以加品牌。
- 误区:品牌类型能替代后端校验。 它不能验证外部输入的真实性。
- 追问:为什么 TypeScript 需要品牌类型? 因为结构化类型无法区分相同结构的不同业务语义。
- 追问:品牌字段为什么常用 readonly? 避免被业务代码当成可写数据字段。
八、加强记忆
记忆链路:
raw string -> validate -> branded string -> domain API
回答时先讲结构化类型的混用问题,再给 UserId/OrderId 例子,最后强调品牌类型只负责编译期隔离,运行时仍要校验。