前端开发··1 阅读·预计 9 分钟

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); // ❌ 编译通过!运行时查错表,返回空或越权数据

userIdorderId 都是 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 不是银弹,需注意三点:

  1. 断言仍是后门as 可以绕过任何保护,所以工厂函数 + 运行时校验才是完整方案。
  2. 序列化还原:从 JSON.parse、API 响应拿回的仍是裸 string/number,需要在边界处重新标注(这正是品牌应该集中的地方)。
  3. 跨层传播成本:若团队不遵守约定,Brand 会退化成“人人都 as”的摆设,需要配合 Lint 规则约束 as 的使用。

总结

Branded Types 用零运行时成本,把 UserIdOrderIdMillisecondsPrice 这类语义型基本量从“裸 string/number”升级为不可混用的命名类型。它的工程价值在于:

  • 编译期拦截领域 ID / 单位 / 金额的混用;
  • 收敛标注点到工厂函数,同时获得运行时校验;
  • 自我文档化,函数签名本身就说明了参数语义。

当你的代码库里遍布 id: stringamount: number 时,是时候引入 Brand 了。

0 评论

评论区

登录 后参与评论