TypeScript 中的品牌类型(Branded Types):用类型系统防止 ID 混用

在大型应用开发中,我们经常会遇到这样的场景:一个函数需要接收用户 ID,但调用方不小心传入了一个订单 ID。由于两者在 TypeScript 中都是 stringnumber,编译器不会报错,问题可能在运行时才暴露出来,甚至更糟——悄无声息地产生错误数据。品牌类型(Branded Types)正是解决这类问题的利器。

什么是品牌类型?

品牌类型(Branded Types),有时也叫不透明类型(Opaque Types)或名义类型(Nominal Types),是一种在结构类型系统(Structural Type System)中模拟名义类型的技术。TypeScript 的类型系统是结构化的:只要两个类型的结构兼容,它们就可以互相赋值。这带来了灵活性,但也意味着 UserIdOrderId 如果底层都是 string,就无法区分。

品牌类型通过在类型上附加一个“品牌”标记,让编译器将逻辑上不同的类型视为不兼容。这个标记在运行时并不存在,纯粹是编译期的约束,因此不会带来任何运行时开销。

为什么需要品牌类型?

考虑以下代码:

function getUserById(id: string) {
  // 查询用户
}

function getOrderById(id: string) {
  // 查询订单
}

const orderId = "order_123";
getUserById(orderId); // 编译通过,但逻辑错误

这段代码能正常编译,但把订单 ID 传给了用户查询函数,显然是 bug。在小型项目中,开发者可能靠命名约定来避免,但随着代码规模增长,这种约定非常脆弱。品牌类型可以让编译器直接拒绝这种调用。

实现品牌类型

实现品牌类型有多种方式,各有优劣。下面介绍几种常见方案。

方案一:交叉类型 + 唯一符号

最经典的方式是使用交叉类型和一个“品牌”属性:

declare const brand: unique symbol;

type Brand<T, B> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

这里 unique symbol 保证了品牌属性是唯一的,无法被外部构造。Brand 是一个泛型工具类型,接受基础类型 T 和品牌标签 B

使用时,我们需要一个构造函数来创建品牌类型:

function createUserId(value: string): UserId {
  return value as UserId;
}

function createOrderId(value: string): OrderId {
  return value as OrderId;
}

现在,UserIdOrderId 虽然底层都是 string,但彼此不兼容:

function getUserById(id: UserId) { /* ... */ }

const userId = createUserId("user_123");
const orderId = createOrderId("order_456");

getUserById(userId);  // ✅ 正确
getUserById(orderId); // ❌ 编译错误

方案二:使用接口模拟不透明类型

另一种方式是用接口包裹:

interface UserId {
  readonly __brand: "UserId";
  readonly value: string;
}

interface OrderId {
  readonly __brand: "OrderId";
  readonly value: string;
}

这种方式更显式,但需要访问 .value 才能拿到原始值,使用起来稍显繁琐。交叉类型方案更轻量,通常更受欢迎。

方案三:使用 Zod 等库

如果项目中已经使用了 Zod,可以直接利用其 .brand() 方法:

import { z } from "zod";

const UserIdSchema = z.string().brand("UserId");
const OrderIdSchema = z.string().brand("OrderId");

type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;

这种方式的好处是类型和运行时校验统一,特别适合处理来自 API 或表单的外部数据。

品牌类型的实际应用场景

1. 区分不同实体的 ID

这是最典型的场景。在一个电商系统中,你可能同时有 UserIdOrderIdProductIdPaymentId 等。使用品牌类型后,任何 ID 混用都会在编译期被捕获。

2. 区分单位

数值类型也常常需要品牌化。例如,区分米和英尺:

type Meters = Brand<number, "Meters">;
type Feet = Brand<number, "Feet">;

function addMeters(a: Meters, b: Meters): Meters {
  return (a + b) as Meters;
}

这样就不会不小心把英尺加到米上。类似的还有货币(USD、CNY)、时间单位(秒、毫秒)等。

3. 标记已验证的数据

在处理用户输入时,可以用品牌类型表示“已经过验证”的字符串:

type Email = Brand<string, "Email">;

function validateEmail(input: string): Email | null {
  const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return regex.test(input) ? (input as Email) : null;
}

function sendEmail(to: Email) { /* ... */ }

这样,sendEmail 只能接收经过 validateEmail 的字符串,避免了未验证数据流入敏感函数。

4. 防止路径穿越

在文件系统操作中,可以用品牌类型区分“用户提供的路径”和“已净化的路径”:

type RawPath = Brand<string, "RawPath">;
type SafePath = Brand<string, "SafePath">;

function sanitizePath(path: RawPath): SafePath {
  // 移除 .. 等危险片段
  return path.replace(/\.\./g, "") as SafePath;
}

function readFile(path: SafePath) { /* ... */ }

最佳实践与注意事项

提供统一的构造函数。 不要到处使用 as 断言,而是集中定义 createUserIdcreateOrderId 等函数。这样便于后续修改品牌实现,也更容易审计。

谨慎使用 as 断言。 品牌类型的本质是“欺骗”编译器,因此断言的位置必须可信。最好把断言限制在少数几个构造函数中,其他代码只能通过这些函数获得品牌类型。

考虑运行时校验。 品牌类型只在编译期生效。如果数据来自外部(API、localStorage、用户输入),应该在构造品牌类型时进行运行时校验,否则品牌类型只是虚假的安全感。

与 Zod、io-ts 等库结合。 这些库能在解析数据的同时完成校验和品牌标记,是处理边界数据的理想选择。

不要过度使用。 品牌类型有认知成本。对于内部辅助函数、生命周期很短的变量,可能不值得引入品牌。它最适合用于跨越模块边界、容易混淆的核心领域概念。

总结

品牌类型是 TypeScript 中一个强大却常被忽视的特性。它利用交叉类型和唯一符号,在结构化类型系统中模拟出名义类型的效果,让编译器帮我们区分逻辑上不同但底层相同的类型。在 ID 混用、单位混淆、数据验证等场景下,品牌类型能以零运行时开销换取显著的代码安全性。

当然,它并非银弹。品牌类型需要团队达成共识,需要在构造函数处谨慎处理,也需要与运行时校验配合使用。但当你下次因为传错 ID 而调试到深夜时,或许会想起这个小小的类型技巧——它可能正是你需要的护栏。

未经允许不得转载:任鹏个人博客 » TypeScript 中的品牌类型(Branded Types):用类型系统防止 ID 混用

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏