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

📝 引言

📌 场景/痛点

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

typescript 复制代码
function transferMoney(fromAccountId: string, toAccountId: string, amount: number) { ... }

这极其危险!

因为 fromAccountIdtoAccountId 都是 string,TS 编译器完全认为它们是同一种东西。一旦你在调用时传反了顺序,或者把 orderId 传给了 accountId,编译器屁都不会放一个,只有等到程序在运行时报错(甚至更糟,默默转错了账)。

问题根源 :TypeScript 是结构化类型 (鸭子类型),它只看"形状",不看"名字"。type A = stringtype 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 系统

假设我们有一个电商系统,我们需要严格区分 UserIdProductId

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 = stringtype 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 加上"标签"?

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

相关推荐
芸简新章2 小时前
微前端:从原理到实践,解锁复杂前端架构的模块化密码
前端·架构
pusheng20252 小时前
燃料电池电化学传感器在硫化物固态电池安全监测中的技术优势解析
前端·人工智能·安全
それども2 小时前
Excel文件解析 - SAX和DOM方式的区别
java·前端·excel
それども2 小时前
Excel文件解析 - SAX startRow cell endRow 执行顺序
java·前端·excel
Byron07072 小时前
基于 Vue 的微前端架构落地实战:从 0 到 1 搭建企业级多应用体系
前端·vue.js·架构
一位搞嵌入式的 genius2 小时前
从 URL 到渲染:JavaScript 性能优化全链路指南
开发语言·前端·javascript·性能优化
芭拉拉小魔仙2 小时前
Vue 3 组合式 API 详解:告别 Mixins,拥抱函数式编程
前端·javascript·vue.js
别叫我->学废了->lol在线等2 小时前
taiwindcss的一些用法
前端·javascript
感谢地心引力2 小时前
在Chrome浏览器中使用Gemini,附一键开启方法
前端·chrome·ai·gemini