上个季度,我们上线了一个 bug:把退款打给了错误的客户。一个 processRefund 函数在预期接收订单 ID 的地方收到了用户 ID。两个字符串看起来一模一样,测试通过了,TypeScript 也没有任何异议。
两个标识符的类型都是 string。TypeScript 看不出它们之间的区别。如果你曾经把 userId 传给了需要 orderId 的参数,然后看着编译器耸耸肩,你就碰到了同样的墙。
类型别名解决不了这个问题。仔细命名变量也不行。编译器正在精确地做你告诉它的事,而这正是问题所在。
为什么 type UserId = string 是一个谎言
TypeScript 使用 structural typing。如果两个类型的结构匹配,它们就是兼容的。当你写下 type UserId = string 时,你并没有创建一个新类型,你只是创建了一个别名。在编译时,UserId 和 string 是完全相同的,UserId 和 OrderId 也是如此。
type UserId = string;
type OrderId = string;
function fetchOrder(id: OrderId) {
// ...
}
const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.
编译器在类型检查时会剥离类型别名。UserId 这个名字是给人看的,类型检查器会忽略它。这通常是一个特性,它让你可以无障碍地替换实现。但对于那些永远不应该互换的标识符来说,这是一把指向自己脚的枪。
Branded types 让相同的结构变得不兼容
解决方案是 branded type,有时也叫 opaque type 或 newtype。你把底层类型和一个仅在类型层面存在的唯一 brand 进行交叉。
在运行时,这个值仍然只是一个字符串。在编译时,brand 让它与所有其他字符串区分开来。
下面是使用 unique symbol 的模式:
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };
declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };
unique symbol 保证了没有其他类型会意外共享这个 brand。readonly 属性意味着你无法把它变异掉。在运行时,symbol 属性并不存在于实际的字符串上,所以这纯粹是一个编译时构造。
要创建一个值,你需要一个构造函数来转换原始字符串:
function UserId(value: string): UserId {
return value as UserId;
}
function OrderId(value: string): OrderId {
return value as OrderId;
}
现在,之前的 bug 在代码运行之前就会被捕获:
const uid = UserId("usr_0192");
fetchOrder(uid);
// ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
错误信息很清晰。因为这些 brand 不同,类型在结构上也就不同。编译器不会让你把它们混在一起。
样板代码很烦人。这里有个辅助工具。
为每种 ID 类型写一个 brand 和一个构造函数很快就会让人厌倦。我们使用一个小工具来同时生成两者:
interface Brand<T> {
readonly __brand: T;
}
type Branded<T, B> = T & Brand<B>;
function makeBrand<T, B>(
_brand: B
): (value: T) => Branded<T, B> {
return (value) => value as Branded<T, B>;
}
用法:
type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");
type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");
这样每个标识符的声明只需要两行。如果你更喜欢更强的防冲突能力,也可以在泛型辅助工具中使用 unique symbol 的方式。无论哪种方式,目标都是一样的:让添加一个新的 branded type 的成本足够低,低到你真的会去用。
那对象和数字呢?
Branded types 适用于任何原始类型,不只是字符串。我们用同样的方式来 brand 数字 ID:
type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");
你也可以给对象打 brand。如果你有两个配置,它们的形状相同但永远不应该互换,就给它们打上 brand:
type ApiConfig = Branded<{
endpoint: string;
timeout: number;
}, "ApiConfig">;
不过,给对象打 brand 时要小心。运行时对象不会有 brand 属性,所以 JSON.stringify 和展开操作都能正常执行。这通常正是你想要的。但如果你在做深相等检查,或者把值传给那些在运行时检查类型的库,brand 就帮不上忙了。它严格来说只是一个编译时守卫。
权衡是真实存在的,但很小
Branded types 会增加摩擦。现在每个 ID 都需要一次构造函数调用。你不能把原始字符串直接内联到期望 branded type 的函数中。这正是重点所在,但它意味着更多的代码。
序列化是另一个需要注意的地方。当你对 branded string 执行 JSON.stringify 时,你会得到原始字符串。当你稍后解析它时,brand 已经丢失了。你需要在系统边界重新应用构造函数。我们通常在 API 响应解析器和数据库行映射器中做这件事。
const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));
Branded types 对运行时验证也帮不上忙。如果一个字符串格式错误,brand 不会捕获它。你仍然需要 zod、valibot,或者在系统边界进行手动验证。Brand 保证的是类型的独立性,而不是数据的正确性。
什么时候值得用,什么时候只是噪音
我们会给跨越模块边界的标识符打 brand:用户 ID、组织 ID、trace ID、span ID。这些值在代码中流传最广,最有可能被传错顺序。
我们不会给内部循环计数器、局部临时变量,或者作用域小于函数的任何变量打 brand。对于那些永远不会离开诞生地的值,这种开销是不值得的。
如果你的代码库里有连续三个字符串参数的函数签名,那是一个强烈的信号。Branded types 能把那个调用点从猜谜游戏变成编译器帮你检查的东西。
一个实用的起点
你不需要明天就给代码库里的每个字符串都打上 brand。挑选那些曾经引发过真实 bug 的标识符,给它们加上 brand。看着编译器在你下次重构时捕获一个混淆。就在那一刻——当一个构建错误阻止了一个线上 bug 时——那些额外的构造函数调用开始显得很廉价。
从一个模块边界开始。加上 UserId brand。加上 OrderId brand。更新你的数据库层,让它在数据行进来时应用构造函数。看看感觉如何。如果它在代码审查中捕获了一个错误的参数,它就已经回本了。