在大型应用开发中,我们经常会遇到这样的场景:一个函数需要接收用户 ID,但调用方不小心传入了一个订单 ID。由于两者在 TypeScript 中都是 string 或 number,编译器不会报错,问题可能在运行时才暴露出来,甚至更糟——悄无声息地产生错误数据。品牌类型(Branded Types)正是解决这类问题的利器。
什么是品牌类型?
品牌类型(Branded Types),有时也叫不透明类型(Opaque Types)或名义类型(Nominal Types),是一种在结构类型系统(Structural Type System)中模拟名义类型的技术。TypeScript 的类型系统是结构化的:只要两个类型的结构兼容,它们就可以互相赋值。这带来了灵活性,但也意味着 UserId 和 OrderId 如果底层都是 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;
}
现在,UserId 和 OrderId 虽然底层都是 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
这是最典型的场景。在一个电商系统中,你可能同时有 UserId、OrderId、ProductId、PaymentId 等。使用品牌类型后,任何 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 断言,而是集中定义 createUserId、createOrderId 等函数。这样便于后续修改品牌实现,也更容易审计。
谨慎使用 as 断言。 品牌类型的本质是“欺骗”编译器,因此断言的位置必须可信。最好把断言限制在少数几个构造函数中,其他代码只能通过这些函数获得品牌类型。
考虑运行时校验。 品牌类型只在编译期生效。如果数据来自外部(API、localStorage、用户输入),应该在构造品牌类型时进行运行时校验,否则品牌类型只是虚假的安全感。
与 Zod、io-ts 等库结合。 这些库能在解析数据的同时完成校验和品牌标记,是处理边界数据的理想选择。
不要过度使用。 品牌类型有认知成本。对于内部辅助函数、生命周期很短的变量,可能不值得引入品牌。它最适合用于跨越模块边界、容易混淆的核心领域概念。
总结
品牌类型是 TypeScript 中一个强大却常被忽视的特性。它利用交叉类型和唯一符号,在结构化类型系统中模拟出名义类型的效果,让编译器帮我们区分逻辑上不同但底层相同的类型。在 ID 混用、单位混淆、数据验证等场景下,品牌类型能以零运行时开销换取显著的代码安全性。
当然,它并非银弹。品牌类型需要团队达成共识,需要在构造函数处谨慎处理,也需要与运行时校验配合使用。但当你下次因为传错 ID 而调试到深夜时,或许会想起这个小小的类型技巧——它可能正是你需要的护栏。
未经允许不得转载:任鹏个人博客 » TypeScript 中的品牌类型(Branded Types):用类型系统防止 ID 混用


朋友圈点赞图在线生成源码