前端开发··2 阅读·预计 7 分钟

品牌类型的工程化落地:用名义类型终结 TypeScript 的原始类型痴迷

一个真实的事故现场

假设你有两个接口,一个按用户 ID 查用户,一个按订单 ID 查订单:

function getUser(id: string) { return api.get(`/users/${id}`) }
function getOrder(id: string) { return api.get(`/orders/${id}`) }

getUser(orderId) // 编译通过,运行时 404

orderIdstringuserId 也是 string。TypeScript 的结构化类型(structural typing)认为二者完全等价——类型层面没有任何东西能阻止你把订单 ID 传给用户接口。这种把业务语义压平成一坨 string / number 的做法,业界叫原始类型痴迷(Primitive Obsession)。它不会在编译期报错,只会在凌晨三点的线上告警里冒出来。

品牌类型(Branded Type)是什么

解法是给基础类型贴一个只存在于类型层的"标签",让它从结构化等价变成名义唯一:

type Brand<T, B extends string> = T & { readonly __brand: B }

type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>

关键在于 __brand 这个字段:它只写在类型里,运行时根本不存在。于是 UserIdOrderId 虽然底层都是 string,但类型上互不兼容:

function getUser(id: UserId) { return api.get(`/users/${id}`) }
function getOrder(id: OrderId) { return api.get(`/orders/${id}`) }

getUser(orderId) // ❌ 编译报错:OrderId 不能赋给 UserId
getUser(userId)   // ✅ 正确

从这行开始,参数错位这类事故被编译器挡在了 CI 门前。

反例:别让裸 string 一路裸奔

没有品牌的版本,错误要等到运行时才现形,而且现形的方式往往是"数据查不到"而非"明显报错":

// ❌ 反例:语义丢失,全靠命名规范自欺
async function refund(accountId: string, amount: number, currency: string) {
  // accountId 传成 userId 也照样跑,扣错钱才发现
}

refund(userId, 100, 'CNY') // 静默通过,钱扣到了错误的上下文

命名约定(accountId vs userId)是给人看的,编译器不看。真正能兜底的,只有让类型承载语义。

正例:品牌 + 构造器封装,防止绕过

如果只是给变量手动断言 as UserId,流氓程序员(或未来的你)依然可以随便绕过。工程化的做法是收口构造过程,让合法值只能通过受控入口产生:

function makeUserId(raw: string): UserId {
  if (!/^u_[a-z0-9]+$/.test(raw)) {
    throw new Error(`非法 userId: ${raw}`)
  }
  return raw as UserId
}

配合一个轻量校验,品牌类型就从"编译期断言"升级成了"编译期 + 运行时的双层防线":

// 所有 UserId 只能从这里产出,天然成为数据入口
const userId = makeUserId(res.query.id)
getUser(userId) // 类型正确,且值已被校验

再叠加一个品牌校验工具,把重复样板收敛成一行:

const toUserId = branded<string, 'UserId'>((s) => /^u_/.test(s))

这样既保留了类型安全,又不会因为品牌存在而散落一堆重复的合法性判断。

什么时候不该用

品牌类型不是银弹,滥用会适得其反:

// ❌ 反模式:为品牌而品牌,平白增加样板
type Name = Brand<string, 'Name'>        // 没有合法性规则,品牌价值趋近于 0
type Email = Brand<string, 'Email'>      // 如果全项目只有一处用到,收益也很低

品牌类型真正的价值,在于跨边界传递:API 入参、Redux store、跨模块函数签名这类一旦错位就成本高昂的地方。如果某个 string 只在一个函数内部打转,贴品牌反而是在给代码添堵。判断标准很简单——它会不会被误传给另一个语义不同的参数?会,才值得品牌。

工程化落地的三个建议

  1. 从"最贵的事故"开始贴:先给金额、货币、订单号、用户号这些一旦混乱就出大问题的字段上品牌,而不是全量铺开。
  2. 品牌字段别显式暴露__brandneverunique symbol 而非 string,避免有人手动构造:
declare const _brand: unique symbol
type UserId = string & { [_brand]: 'UserId' }
  1. 配合收口构造器,别让 as UserId 满天飞,把断言集中在唯一的工厂函数里。

小结

原始类型痴迷的本质,是把"语义"交给了"约定",而不是交给"类型"。品牌类型用一行 & { __brand } 的成本,把一整类参数错位事故从运行时搬到了编译期。它不是新语法,却是类型系统里性价比极高的一块拼图——当你的 string 开始变得含义模糊,就是时候给它一个品牌了。

0 评论

评论区

登录 后参与评论