TypeScript 领域驱动类型设计实战:用类型系统编码业务规则,让 Bug 无处藏身

深入解析如何用 TypeScript Branded Types、Discriminated Unions 与 Result 模式编码业务规则,将领域知识嵌入类型系统,实现编译期零成本的业务校验。附电商订单系统完整实战代码。

前端开发 2026-05-30 15 分钟

2026 年 Hacker News 上一篇 “Domain expertise has always been the real moat” 引发了 445 条讨论,核心观点是:当 AI 能写出 90% 的样板代码时,真正的竞争壁垒不再是「写代码的速度」,而是「理解业务的深度」。这个观点在技术层面有一个直接映射——用 TypeScript 类型系统编码业务规则(Domain-Driven Type Design),让你的领域知识变成编译器可以验证的契约。Stack Overflow 2026 年调查显示,使用高级类型系统编码业务规则的项目,生产环境类型相关 Bug 减少了 73%,而这些 Bug 在传统方案中往往要到用户投诉才会被发现。

本文将用一个完整的电商订单系统作为案例,展示 4 种核心的类型驱动设计模式:Branded Types、Discriminated Unions、Result 模式和 Schema-First 设计,每种模式都附带可运行的完整代码。

🏷️ 一、Branded Types:消灭「单位混淆」类 Bug

1.1 为什么原始类型不够用

在大多数 TypeScript 项目中,金额用 number、ID 用 string、距离用 number。这导致一个致命问题:编译器无法区分语义不同的同类型值

// ❌ 错误写法:原始类型无法防止语义混淆
function calculateTotal(price: number, quantity: number): number {
  return price * quantity
}

// 以下调用全部能通过编译,但语义全部是错的
calculateTotal(5, 10)      // ✅ 正常:单价5,数量10
calculateTotal(userId, 10)  // ❌ Bug!userId 被当作价格
calculateTotal(10, 5.99)    // ❌ Bug!价格和数量传反了
calculateTotal(-100, 5)     // ❌ Bug!负数价格

1.2 Branded Types 实战

Branded Types(品牌类型)通过交叉类型在编译期为原始类型「打标签」,让 TypeScript 编译器在赋值和传参时自动检查语义匹配。

// ✅ 正确写法:用 Branded Types 编码领域语义

// 品牌类型基础工具
type Brand<T, B extends string> = T & { readonly __brand: B }

// 定义领域类型
type Money = Brand<number, 'Money'>
type Quantity = Brand<number, 'Quantity'>
type OrderId = Brand<string, 'OrderId'>
type UserId = Brand<string, 'UserId'>

// 类型安全的构造函数(带运行时校验)
function createMoney(amount: number): Money {
  if (amount < 0) throw new Error('金额不能为负数')
  if (!Number.isFinite(amount)) throw new Error('金额必须是有限数')
  if (Math.round(amount * 100) !== amount * 100) throw new Error('金额最多两位小数')
  return amount as Money
}

function createQuantity(qty: number): Quantity {
  if (!Number.isInteger(qty) || qty < 1) throw new Error('数量必须为正整数')
  return qty as Quantity
}

function createOrderId(id: string): OrderId {
  if (!/^ORD-\d{8,}$/i.test(id)) throw new Error('订单号格式不合法')
  return id as OrderId
}

function createUserId(id: string): UserId {
  if (!id.startsWith('USR-')) throw new Error('用户ID格式不合法')
  return id as UserId
}

// 类型安全的业务函数
function calculateLineTotal(price: Money, quantity: Quantity): Money {
  return createMoney(price * quantity)
}

// 现在这些调用全部会编译报错 ✅
// calculateLineTotal(userId, createQuantity(5))   // ❌ 类型错误
// calculateLineTotal(createMoney(10), orderId)     // ❌ 类型错误
// calculateLineTotal(createMoney(-100), createQuantity(5)) // 运行时校验拦截

1.3 Branded Types 性能开销

Branded Types 是纯编译期特性,运行时零开销——__brand 字段在编译后完全消失,不产生任何额外的对象属性或内存占用。以下是编译前后的对比:

特性 编译时(TypeScript) 运行时(JavaScript) 性能影响
Branded Type 标记 存在 消失 零开销
构造函数校验 类型检查 if 判断执行 微秒级
类型断言 as 编译期验证 无操作 零开销
联合类型判别 类型窄化 属性访问 纳秒级

💡 提示: Branded Types 的价值在于「快速失败」——把运行时才发现的参数传反、单位混淆等问题,提前到编译期就暴露。对于金额、ID、度量单位等场景,这是零成本的防御性编程。

🔀 二、Discriminated Unions:用类型编码状态机

2.1 业务状态的类型表达

电商订单有明确的生命周期:待支付 → 已支付 → 已发货 → 已签收/已退款。传统的做法是用一个 status 字段配合大量 if-else,但编译器无法帮你检查状态转换的合法性。

Discriminated Unions(可辨识联合类型)让编译器理解你的业务状态机,在每个分支中自动窄化类型,提供精确的字段提示和非法状态的编译期报错。

// ✅ 用 Discriminated Unions 编码订单状态机

interface PendingOrder {
  readonly status: 'pending'
  readonly orderId: OrderId
  readonly items: ReadonlyArray<{ name: string; price: Money; quantity: Quantity }>
  readonly createdAt: Date
  readonly expireAt: Date  // 待支付状态独有:支付截止时间
}

interface PaidOrder {
  readonly status: 'paid'
  readonly orderId: OrderId
  readonly items: ReadonlyArray<{ name: string; price: Money; quantity: Quantity }>
  readonly paidAt: Date
  readonly paidAmount: Money
  readonly paymentMethod: 'alipay' | 'wechat' | 'card'
}

interface ShippedOrder {
  readonly status: 'shipped'
  readonly orderId: OrderId
  readonly items: ReadonlyArray<{ name: string; price: Money; quantity: Quantity }>
  readonly paidAt: Date
  readonly shippedAt: Date
  readonly trackingNumber: string
  readonly carrier: 'sf' | 'jd' | 'zt' | 'yd'
}

interface DeliveredOrder {
  readonly status: 'delivered'
  readonly orderId: OrderId
  readonly deliveredAt: Date
  readonly signedBy: string
}

interface RefundedOrder {
  readonly status: 'refunded'
  readonly orderId: OrderId
  readonly refundedAt: Date
  readonly refundAmount: Money
  readonly reason: string
}

// 订单类型 = 所有可能状态的联合
type Order = PendingOrder | PaidOrder | ShippedOrder | DeliveredOrder | RefundedOrder

// 类型安全的状态处理器
function getOrderDisplay(order: Order): string {
  switch (order.status) {
    case 'pending':
      // TypeScript 知道这里 order 是 PendingOrder,自动提供 expireAt
      return `待支付,${formatDeadline(order.expireAt)}后自动取消`
    case 'paid':
      // 这里 order 是 PaidOrder,可以安全访问 paidAmount
      return `已支付 ¥${order.paidAmount},等待发货`
    case 'shipped':
      // 这里 order 是 ShippedOrder,可以安全访问 trackingNumber
      return `已发货,${order.carrier === 'sf' ? '顺丰' : '其他'}快递 ${order.trackingNumber}`
    case 'delivered':
      return `已签收,签收人:${order.signedBy}`
    case 'refunded':
      return `已退款 ¥${order.refundAmount},原因:${order.reason}`
  }
}

// ❌ 编译器会阻止非法字段访问
// function badHandler(order: Order) {
//   return order.trackingNumber  // ❌ 编译报错:PendingOrder 上不存在 trackingNumber
// }

2.2 状态转换的类型守卫

更进一步,我们可以用类型守卫(Type Guard)函数约束合法的状态转换路径:

// ✅ 类型安全的状态转换守卫
function canShip(order: Order): order is PaidOrder {
  return order.status === 'paid'
}

function canRefund(order: Order): order is PaidOrder | ShippedOrder {
  return order.status === 'paid' || order.status === 'shipped'
}

function shipOrder(order: Order, trackingNumber: string, carrier: ShippedOrder['carrier']): Order {
  if (!canShip(order)) {
    throw new Error(`订单 ${order.orderId} 当前状态为 ${order.status},无法发货`)
  }
  return {
    status: 'shipped',
    orderId: order.orderId,
    items: order.items,
    paidAt: order.paidAt,
    shippedAt: new Date(),
    trackingNumber,
    carrier,
  }
}

⚠️ 警告: Discriminated Unions 的 status 字段必须用 readonly 修饰,否则外部代码可以直接修改状态绕过类型检查。同时建议开启 TypeScript 的 strictNullChecksnoUncheckedIndexedAccess 选项,让类型系统发挥最大威力。

🛡️ 三、Result 模式:用类型代替 try-catch

3.1 try-catch 的三个致命缺陷

传统的 try-catch 异常处理在业务代码中有三个严重问题:

  • 不可见性:函数签名不暴露可能的错误,调用者不知道需要处理哪些异常
  • 控制流混乱:异常会跳过正常的执行路径,难以推理
  • 性能开销:V8 对 try-catch 块的优化有限,热路径上影响显著

Result 模式(也叫 Either 模式)用类型显式表达「成功或失败」,让错误处理变成类型系统的一部分。

// ✅ Result 模式实现:类型安全的错误处理

type Result<T, E = Error> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E }

// 成功/失败构造函数
function Ok<T>(value: T): Result<T, never> {
  return { ok: true, value }
}

function Err<E>(error: E): Result<never, E> {
  return { ok: false, error }
}

// 定义业务错误类型
type OrderError =
  | { code: 'INSUFFICIENT_STOCK'; product: string; available: number; requested: number }
  | { code: 'PAYMENT_FAILED'; reason: string; retryable: boolean }
  | { code: 'ORDER_EXPIRED'; orderId: OrderId; expiredAt: Date }
  | { code: 'INVALID_ADDRESS'; field: string; message: string }

// 类型安全的业务函数——错误作为返回类型的一部分
function placeOrder(
  userId: UserId,
  items: ReadonlyArray<{ productId: string; quantity: Quantity }>
): Result<OrderId, OrderError> {
  // 检查库存
  for (const item of items) {
    const stock = getStock(item.productId)
    if (stock < item.quantity) {
      return Err({
        code: 'INSUFFICIENT_STOCK',
        product: item.productId,
        available: stock,
        requested: item.quantity,
      })
    }
  }

  // 创建订单
  const orderId = generateOrderId()
  // ... 业务逻辑
  return Ok(orderId)
}

// 调用者必须处理两种情况——编译器强制
const result = placeOrder(userId, items)
if (result.ok) {
  console.log(`订单创建成功:${result.value}`)
  // TypeScript 知道 result.value 是 OrderId
} else {
  switch (result.error.code) {
    case 'INSUFFICIENT_STOCK':
      console.error(`库存不足:${result.error.product},仅剩 ${result.error.available}`)
      break
    case 'PAYMENT_FAILED':
      console.error(`支付失败:${result.error.reason}`)
      if (result.error.retryable) showRetryButton()
      break
    case 'ORDER_EXPIRED':
      console.error(`订单已过期:${result.error.orderId}`)
      break
    case 'INVALID_ADDRESS':
      highlightField(result.error.field, result.error.message)
      break
  }
}

3.2 Result 链式操作

Result 模式真正的威力在于链式组合——像 Promise 的 .then() 一样安全地组合多个可能失败的操作:

// ✅ Result 链式操作:安全组合多个可能失败的步骤

function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  return result.ok ? Ok(fn(result.value)) : result
}

function flatMap<T, U, E>(result: Result<T, E>, fn: (value: T) => Result<U, E>): Result<U, E> {
  return result.ok ? fn(result.value) : result
}

function mapError<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
  return result.ok ? result : Err(fn(result.error))
}

// 链式组合:校验地址 → 创建订单 → 发起支付
function checkout(userId: UserId, cart: Cart, address: Address): Result<PaymentUrl, OrderError> {
  return pipe(
    validateAddress(address),                               // Result<Address, OrderError>
    (addr) => createOrder(userId, cart, addr),              // Result<OrderId, OrderError>
    (orderId) => initiatePayment(orderId, cart.total),      // Result<PaymentUrl, OrderError>
  )
}

// pipe 辅助函数
function pipe<T>(value: T, ...fns: Array<(arg: any) => any>): any {
  return fns.reduce((acc, fn) => fn(acc), value)
}

📌 记住: Result 模式不是要完全取代 try-catch。对于真正的「异常」情况(数据库连接断开、OOM),使用 throw 是合理的。Result 模式适用于可预期的业务错误(库存不足、支付失败、参数校验),这些错误是业务流程的正常分支,不应该用异常来表达。

🏗️ 四、Schema-First 设计:编译期 + 运行时双保险

4.1 用 Zod 桥接类型与校验

Branded Types 和 Discriminated Unions 解决了编译期安全,但来自外部的数据(API 请求、表单输入、数据库查询)在运行时是不受 TypeScript 保护的。Schema-First 设计用 Zod 同时定义校验规则和 TypeScript 类型,实现「一份 Schema,两层安全」。

// ✅ Schema-First:一份 Zod Schema 同时生成运行时校验 + 编译期类型
import { z } from 'zod'

// 定义 Zod Schema
const MoneySchema = z.number()
  .nonnegative('金额不能为负数')
  .finite('金额必须是有限数')
  .refine((n) => Math.round(n * 100) === n * 100, '金额最多两位小数')

const QuantitySchema = z.number()
  .int('数量必须为整数')
  .positive('数量必须大于 0')

const OrderItemSchema = z.object({
  productId: z.string().min(1),
  name: z.string().min(1).max(200),
  price: MoneySchema,
  quantity: QuantitySchema,
})

const CreateOrderRequestSchema = z.object({
  userId: z.string().startsWith('USR-'),
  items: z.array(OrderItemSchema).min(1, '至少需要一个商品').max(50, '单笔订单最多 50 个商品'),
  address: z.object({
    province: z.string().min(1),
    city: z.string().min(1),
    district: z.string().min(1),
    detail: z.string().min(5).max(200),
    phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
    name: z.string().min(1).max(50),
  }),
  couponCode: z.string().optional(),
})

// 从 Schema 推导 TypeScript 类型——保证类型与校验永远同步
type CreateOrderRequest = z.infer<typeof CreateOrderRequestSchema>

// Express/Koa/H3 路由中使用
async function handleCreateOrder(req: Request): Promise<Response> {
  const body = await req.json()
  const parsed = CreateOrderRequestSchema.safeParse(body)

  if (!parsed.success) {
    return Response.json(
      {
        code: 400,
        message: '请求参数校验失败',
        errors: parsed.error.issues.map((issue) => ({
          path: issue.path.join('.'),
          message: issue.message,
        })),
      },
      { status: 400 }
    )
  }

  // 此处 parsed.data 已经是类型安全的 CreateOrderRequest
  const result = placeOrder(parsed.data)
  return Response.json(result)
}

4.2 四种模式的协作架构

在实际项目中,这四种模式通常协同工作,形成完整的类型安全防线:

层级 使用模式 作用 典型工具
API 入口层 Schema-First (Zod) 校验外部输入 Zod, Valibot, ArkType
领域模型层 Branded Types 防止单位/语义混淆 自定义 Brand 工具
状态管理层 Discriminated Unions 编码业务状态机 TypeScript 原生
错误处理层 Result 模式 类型安全的错误传播 neverthrow, Effect

⚠️ 警告: 不要在所有场景都使用 Result 模式。对于数据库连接失败、OOM 等真正的基础设施异常,throw + 全局错误中间件是更合理的选择。过度使用 Result 会让代码变成「类型体操」而非业务逻辑。

💡 五、实战案例:电商订单系统完整实现

将以上四种模式组合起来,一个类型安全的订单创建流程如下:

// ✅ 完整的类型安全订单创建流程
import { z } from 'zod'

// 1. Schema-First:校验外部输入
const parsed = CreateOrderRequestSchema.safeParse(requestBody)
if (!parsed.success) return validationError(parsed.error)

// 2. Branded Types:构造类型安全的领域值
const userId = createUserId(parsed.data.userId)
const items = parsed.data.items.map((item) => ({
  ...item,
  price: createMoney(item.price),
  quantity: createQuantity(item.quantity),
}))

// 3. Discriminated Unions:处理订单状态
const orderResult = placeOrder(userId, items)

// 4. Result 模式:安全处理可能的失败
if (!orderResult.ok) {
  return mapBusinessError(orderResult.error)
}

// 全链路类型安全,无需运行时类型断言
return created({ orderId: orderResult.value })

这套方案的核心优势是编译器替你做 Code Review。当团队新成员写了 placeOrder(userId, 'invalid') 时,TypeScript 编译器直接报错,而不是等到 QA 测试或线上报警才发现问题。

🎯 总结与最佳实践

实践 推荐 说明
金额/距离用 Branded Types ✅ 推荐 零运行时开销,防止单位混淆
业务状态用 Discriminated Unions ✅ 推荐 编译器强制检查所有分支
可预期错误用 Result ✅ 推荐 错误可见、可组合、可测试
外部输入用 Zod Schema ✅ 推荐 一份定义,两层安全
所有数字都打 Brand ❌ 避免 过度使用会降低代码可读性
基础设施异常用 Result ❌ 避免 该 throw 就 throw
生产环境关闭 strict 模式 ❌ 避免 等于放弃了 80% 的类型安全价值

关键结论: 类型驱动的领域设计不是「类型体操炫技」,而是一种编译期的单元测试。每一个 Branded Type 都是一个测试用例,每一个 Discriminated Union 都是一个状态机文档,每一个 Result 类型都是一份错误契约。当 AI 能帮你写出 90% 的代码时,剩下 10% 定义业务规则和类型约束的工作,才是真正的「护城河」。

相关工具推荐:

📚 相关文章