📝 引言
📌 场景/痛点
在业务代码中,我们经常看到这样的函数签名:
typescript
function transferMoney(fromAccountId: string, toAccountId: string, amount: number) { ... }
这极其危险!
因为 fromAccountId 和 toAccountId 都是 string,TS 编译器完全认为它们是同一种东西。一旦你在调用时传反了顺序,或者把 orderId 传给了 accountId,编译器屁都不会放一个,只有等到程序在运行时报错(甚至更糟,默默转错了账)。
问题根源 :TypeScript 是结构化类型 (鸭子类型),它只看"形状",不看"名字"。type A = string 和 type B = string 是等价的。
我们需要的是"名义化类型",让业务上的不同概念在类型层面也严格区分。
✨ 最终效果
引入"品牌类型"后,你将获得业务级的类型安全。把 UserId 传给需要 OrderId 的参数,IDE 会直接亮红灯。
📖 内容概览
本文将带你掌握 TS 在业务建模中的高级用法:
- 核心痛点:为什么普通类型无法区分业务概念?
- 品牌类型:利用交叉类型实现"名义化"。
- 实战模式 :创建安全的
ID工厂函数。 - DDD 思维:用类型系统映射业务领域。
🛠️ 正文
1. 核心概念:品牌类型
TS 本身不支持"名义类型",但我们可以通过"交叉类型"模拟它。
原理是:给一个基础类型(如 string)打上一个不可见的"标签"。
typescript
// ✨ 品牌类型的通用定义
// T 是基础类型 (如 string, number)
// B 是品牌名 (如 'UserId')
export type Brand<T, B> = T & { readonly __brand: B };
2. 实战演练:构建安全的 ID 系统
假设我们有一个电商系统,我们需要严格区分 UserId 和 ProductId。
2.1 定义类型
typescript
// src/types/brands.ts
// 基础定义:本质上还是 string,但带上了 'User' 的标签
export type UserId = Brand<string, 'User'>;
export type ProductId = Brand<string, 'Product'>;
2.2 定义工厂函数
为了防止有人直接写 const id: UserId = 'abc' (这实际上是被允许的,因为 'abc' 是 string 的子集),我们应该使用工厂函数来强制创建。
typescript
export const UserId = {
create: (value: string): UserId => {
// 在这里可以加入校验逻辑,比如格式检查
if (!value.startsWith('user_')) {
throw new Error('Invalid UserId format');
}
return value as UserId; // 类型断言
}
};
export const ProductId = {
create: (value: string): ProductId => {
if (!value.startsWith('prod_')) {
throw new Error('Invalid ProductId format');
}
return value as ProductId;
}
};
2.3 业务应用
现在,看看类型系统如何保护我们。
typescript
// 🚨 危险的旧代码
function deleteUser(id: string) { ... }
// deleteUser(productId) // 编译通过!运行时BUG!
// ✅ 安全的新代码
function deleteUserSafe(id: UserId) { ... }
const uId = UserId.create('user_001');
const pId = ProductId.create('prod_101');
// ✅ 正确调用
deleteUserSafe(uId);
// ❌ 报错:类型 'ProductId' 不能赋值给类型 'UserId'
deleteUserSafe(pId);
3. 进阶:不只是 ID,还有值对象
在 DDD(领域驱动设计)中,我们不仅区分 ID,还区分值对象 。比如"金额"和"数量"虽然都是 number,但绝不能混用。
typescript
// 区分"金额"和"数量"
export type Money = Brand<number, 'Money'>;
export type Quantity = Brand<number, 'Quantity'>;
function calculateTotal(price: Money, qty: Quantity): Money {
return (price * qty) as Money;
}
const price = 100 as Money;
const count = 5 as Quantity;
// ✅ 正确
const total = calculateTotal(price, count);
// ❌ 报错:防止把数量当成金额传进去
// calculateTotal(count, price);
4. 运行时行为
因为品牌类型本质上是 T & { readonly __brand: B },而这个 __brand 属性在编译后会被完全擦除。
这意味着:
- 零运行时开销 :最终生成的 JS 代码里,
UserId就是普通的string。 - 序列化友好 :你可以安全地把
UserId发送给后端(后端收到的是字符串),或者在 LocalStorage 里存它。
❓ 常见问题
Q1: type A = string 和 type UserId = string & { __brand: 'User' } 生成的 JS 代码一样吗?
A: 是的,完全一样。 __brand 只是一个编译时的类型标记,不会出现在最终的 JS 代码中。这使得品牌类型既安全,又没有任何性能负担。
Q2: 如果后端返回的是普通字符串,怎么转成 UserId?
A: 你需要有一个"边界转换层"。在 API 请求函数中,拿到数据后,使用工厂函数进行转换。
typescript
const rawUser = await fetchUser();
// ⚠️ 不要在后端传 JSON 数据里带类型信息,在前端清洗
const safeUser = {
...rawUser,
id: UserId.create(rawUser.id) // 类型清洗
};
Q3: 写 as string 可以把品牌类型去掉吗?
A: 可以。类型系统是防君子不防小人的。如果你刻意要用 id as string 强行绕过检查,TS 拦不住你。但在正常的业务流程中,这种强转应该被 Code Review 禁止。
🎯 总结
品牌类型是 TypeScript 赋予开发者的"业务级安全带"。本文我们掌握了:
- 原理 :利用交叉类型
T & { __brand: B }模拟名义类型。 - 实践:通过工厂函数创建 ID 和值对象,杜绝"张冠李戴"。
- DDD 结合:用类型系统表达业务领域的概念,而不仅仅是技术类型。
🚀 下期预告:
我们学习了类型、模块、全栈、品牌类型,现在是时候把这些知识融合在一起了。
下一篇文章将是本系列的收官之作------《终极实战:构建一个类型安全的 UI 组件库》,我们将挑战多态组件和泛型的高级设计。
💬 互动环节:
你的项目里出现过 ID 混用导致的 BUG 吗?看完本文是不是想给所有的 ID 加上"标签"?
如果觉得文章让你的代码更健壮了,请点赞👍、收藏⭐、关注👀,最后一篇千万别错过!