拒绝“都是 string”:品牌类型与领域驱动设计 (DDD)

📝 引言

📌 场景/痛点

在业务代码中,我们经常看到这样的函数签名:

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 在业务建模中的高级用法:

  1. 核心痛点:为什么普通类型无法区分业务概念?
  2. 品牌类型:利用交叉类型实现"名义化"。
  3. 实战模式 :创建安全的 ID 工厂函数。
  4. 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 赋予开发者的"业务级安全带"。本文我们掌握了:

  1. 原理 :利用交叉类型 T & { __brand: B } 模拟名义类型。
  2. 实践:通过工厂函数创建 ID 和值对象,杜绝"张冠李戴"。
  3. DDD 结合:用类型系统表达业务领域的概念,而不仅仅是技术类型。

🚀 下期预告:

我们学习了类型、模块、全栈、品牌类型,现在是时候把这些知识融合在一起了。

下一篇文章将是本系列的收官之作------《终极实战:构建一个类型安全的 UI 组件库》,我们将挑战多态组件和泛型的高级设计。

💬 互动环节:

你的项目里出现过 ID 混用导致的 BUG 吗?看完本文是不是想给所有的 ID 加上"标签"?

如果觉得文章让你的代码更健壮了,请点赞👍、收藏⭐、关注👀,最后一篇千万别错过!

相关推荐
风骏时光牛马19 小时前
大模型底层架构与推理流程AI源码深度剖析
前端
Setsuna_F_Seiei1 天前
前端转型 Agent 开发 06 之 Agent Memory 记忆系统(让 Agent 更智能,更懂你)
前端·agent·ai编程
zhangzeyuaaa1 天前
Ruby 多线程、GVL(GIL)与 Mutex 完全指南
开发语言·前端·ruby
默_笙1 天前
🌊 向量库和 ES 都查不出"关系",我只好给奶茶建了一张人脉网
前端·javascript
优选资讯1 天前
标签打印软件怎么对接 Excel 数据
前端·excel
一个风轻云淡1 天前
GCC 和 GDB命令简单解读
java·linux·前端
u0111026751 天前
图片处理接口如何统一错误信息 前端提示与后端错误码设计
前端·状态模式
福兮说1 天前
浏览器里把 iPhone 的 HEIC 转成 JPG:Chrome 解不开、转完大了七成、拍摄时间全丢,六个坑实测
前端·javascript·图像处理·chrome·ios·iphone·heic
libokaifa1 天前
把语音模型塞进手机:sherpa-onnx 在 Android 上的 ASR / TTS
前端
deli0071 天前
DLA 枝晶生长实测:5000 个粒子集体停摆之后,分形维数收敛到 1.70
前端