TypeScript 名义类型(Branded Types)工程化实践:用类型系统终结 UserId 与 OrderId 的混用灾难
问题:为什么 string 不够用?
TypeScript 默认是结构化类型(Structural Typing),判断两个类型是否兼容只看它们的结构,不看名字。这带来一个隐蔽的工程灾难:
// 两个完全不同的领域 ID
function fetchUser(id: string) { /* 查用户表 */ }
function fetchOrder(id: string) { /* 查订单表 */ }
const userId = 'usr_123';
const orderId = 'ord_456';
fetchUser(orderId); // ❌ 编译通过!运行时查错表,返回空或越权数据
userId 和 orderId 都是 string,彼此可以随意赋值。类型层面它们“完全一样”,但语义上它们分属两个世界。这种 stringly-typed 的缺陷,正是大量线上事故的根源:把订单号当用户号传、把金额当折扣率算、把毫秒当秒用。
核心解法:Branded Types(名义类型)
Branded Types 的思路是:不引入运行时开销,仅用一个类型级标记(brand)让两个 string 在编译期“看起来不一样”。
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
交叉类型 string & { readonly __brand: 'UserId' } 保留了 string 的全部能力,却多了一个独一无二的“烙印”。现在:
const userId = 'usr_123' as UserId;
const orderId = 'ord_456' as OrderId;
function fetchUser(id: UserId) {}
function fetchOrder(id: OrderId) {}
fetchUser(orderId); // ✅ 编译报错:OrderId 不能赋给 UserId
fetchOrder(userId); // ✅ 编译报错
用工厂函数替代裸断言
as 断言是“逃逸口”,滥用它等于打回原形。更工程化的做法是收敛标注点到一个工厂函数:
function makeUserId(raw: string): UserId {
if (!/^usr_/.test(raw)) throw new Error(`Invalid user id: ${raw}`);
return raw as UserId;
}
const uid = makeUserId('usr_123'); // 类型推导为 UserId,且运行时做了校验
这样标注只发生在一处,既保留了类型安全,又附带运行时守卫。
进阶:给数值加单位
金额、毫秒、像素这类“裸 number”同样危险。一个经典 bug 是把不同单位的数值相加:
// ❌ 反例:毫秒和秒混在一起静默算错
function delay(ms: number) {}
const timeoutSec = 5;
delay(timeoutSec * 1000); // 这里对了
delay(timeoutSec); // 这里漏乘 1000,运行时才暴露
用 Brand 给单位上锁:
type Milliseconds = number & { readonly __brand: 'Milliseconds' };
type Seconds = number & { readonly __brand: 'Seconds' };
function delay(ms: Milliseconds) {}
delay(5); // ❌ 编译报错:number 不能赋给 Milliseconds
delay(5 as Milliseconds); // ✅ 显式标注
泛型 Brand,消除样板
逐个手写 & { readonly __brand: ... } 太啰嗦,抽一个通用工具类型:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Milliseconds = Brand<number, 'Milliseconds'>;
type Percent = Brand<number, 'Percent'>;
一行一个领域类型,语义清晰,可复用。
正反例对比:折扣率 vs 金额
type Price = Brand<number, 'Price'>; // 金额:元
// ❌ 反例
type Discount = number; // 折扣率:0~1
function applyDiscount(price: Price, discount: Discount): number {
return price * discount; // 若把 0.2 和 20 弄混,结果是灾难
}
// 调用方
const price = 100 as Price;
const discount = 20; // 意图是 20%,却传了 20
applyDiscount(price, discount); // ❌ 编译通过,运行时算出 2000 元
// ✅ 正例
function applyDiscount(price: Price, discount: Discount): Price {
return (price * discount) as Price;
}
// 调用方必须显式构造“百分数”语义
const discount = 0.2 as Discount;
applyDiscount(price, discount); // ✅ 类型驱动,语义自明
Brand 的威力在于:它逼迫调用方在传参前对自己的值的单位/语义做出显式承诺。
边界与代价
Branded Types 不是银弹,需注意三点:
- 断言仍是后门:
as可以绕过任何保护,所以工厂函数 + 运行时校验才是完整方案。 - 序列化还原:从 JSON.parse、API 响应拿回的仍是裸
string/number,需要在边界处重新标注(这正是品牌应该集中的地方)。 - 跨层传播成本:若团队不遵守约定,Brand 会退化成“人人都
as”的摆设,需要配合 Lint 规则约束as的使用。
总结
Branded Types 用零运行时成本,把 UserId、OrderId、Milliseconds、Price 这类语义型基本量从“裸 string/number”升级为不可混用的命名类型。它的工程价值在于:
- 编译期拦截领域 ID / 单位 / 金额的混用;
- 收敛标注点到工厂函数,同时获得运行时校验;
- 自我文档化,函数签名本身就说明了参数语义。
当你的代码库里遍布 id: string、amount: number 时,是时候引入 Brand 了。
评论区
登录 后参与评论