TypeScript 设计模式实战:工厂、策略、适配器与代理怎样保持类型安全

HTMLPAGE 团队
17 分钟阅读

设计模式在 TypeScript 里不该变成 class 套娃。本文从工厂、策略、适配器和代理四个高频场景出发,讲清如何让抽象保持灵活,同时不牺牲类型安全和可读性。

#TypeScript #Design Patterns #Factory Pattern #Strategy Pattern #Adapter Pattern

很多人一提到“设计模式 + TypeScript”,脑子里马上冒出一套厚重的 class 结构:抽象工厂、基类、接口、子类、再来几层继承。问题在于,前端和 Node 项目里真正需要的,往往不是把经典 OO 图谱原样搬进来,而是把“变化点”抽象出来,同时保证调用方还能读懂类型。

TypeScript 在设计模式里的价值,不是让模式更像 Java,而是让模式更诚实。哪些输入是合法的、哪些策略实现必须覆盖、适配器是否真的完成字段转换、代理是否保留原函数签名,这些都可以由类型系统提前约束。如果模式一上来就把类型信息打成 any,那它带来的通常不是灵活,而是延后的风险。

工厂模式:把“怎么创建”隔离出来,但别把输入做成万能配置桶

工厂模式最适合解决创建逻辑和环境差异问题,比如:不同运行环境要创建不同客户端,不同支付方式要创建不同处理器。

type StorageKind = 'memory' | 'redis'

interface Storage {
  get(key: string): Promise<string | null>
  set(key: string, value: string): Promise<void>
}

function createStorage(kind: StorageKind): Storage {
  switch (kind) {
    case 'memory':
      return createMemoryStorage()
    case 'redis':
      return createRedisStorage()
  }
}

真正要避免的,不是工厂本身,而是把工厂入参做成一个什么都能塞的 Record<string, any>。工厂模式一旦把输入边界放宽,调用方就会失去类型提示,后面连工厂是否真的支持某个组合都看不出来。

策略模式:别用 if else 链拖着业务跑,把变化面显式枚举出来

策略模式最值钱的地方,是把“可替换规则”显式列出来,让新增策略变成扩展而不是改旧逻辑。

type PricingStrategy = 'standard' | 'vip' | 'promotion'

const pricingMap: Record<PricingStrategy, (price: number) => number> = {
  standard: (price) => price,
  vip: (price) => price * 0.9,
  promotion: (price) => price - 30
}

function calcPrice(strategy: PricingStrategy, basePrice: number): number {
  return pricingMap[strategy](basePrice)
}

这里 Record<PricingStrategy, ...> 的意义很大:只要你新增一个策略名,TypeScript 会强制你把实现补齐。这样策略模式不只是结构好看,而是具备穷举约束。

适配器模式:真正重要的是把外部不稳定结构拦在边界外

适配器最常被低估。很多团队明明已经接了多个第三方服务,却还在业务层到处判断“这个平台字段叫 full_name,那个平台叫 displayName”。本质上,这就是缺了适配器层。

type VendorUser = {
  uid: string
  full_name: string
  active_flag: 0 | 1
}

type UserProfile = {
  id: string
  name: string
  isActive: boolean
}

function adaptVendorUser(input: VendorUser): UserProfile {
  return {
    id: input.uid,
    name: input.full_name,
    isActive: input.active_flag === 1
  }
}

适配器模式的关键,不是写一个转换函数,而是把外部世界的不稳定命名和字段结构限制在边界里,别让业务层长期直接面对这些差异。

代理模式:包装额外行为时,最怕把原始签名弄丢

代理模式常用于日志、缓存、权限检查和重试。它最常见的问题是:包装之后,原函数签名丢了,返回值也宽了,调用方只能面对一个模糊函数。

function withTiming<TArgs extends unknown[], TResult>(
  fn: (...args: TArgs) => Promise<TResult>
) {
  return async (...args: TArgs): Promise<TResult> => {
    const start = performance.now()
    try {
      return await fn(...args)
    } finally {
      console.log('cost', performance.now() - start)
    }
  }
}

这类泛型代理的价值在于:你加了额外行为,但原始参数和返回值仍然保留下来。否则代理模式会把“附加控制”变成“类型信息丢失”。

一个常见失败案例:模式是抽象了,类型却退化成 any

很多项目在做所谓“模式升级”时,会出现一种反效果:结构更复杂了,类型却更差了。常见表现有:

  • 工厂接收 config: any
  • 策略表写成对象,但 key 没有联合类型约束
  • 适配器只返回 Record<string, unknown>
  • 代理函数包装后不保留原始签名

这种代码看起来“更有架构感”,实际上却把很多风险从编译期挪回了运行时。模式如果不能提升边界清晰度和替换安全性,通常只是引入了新的复杂度。

决策表:什么时候该上模式,什么时候先别上

场景更适合什么模式不建议做的事
创建逻辑随环境变化工厂把所有可选项塞进一个大配置对象
规则可替换、可扩展策略继续堆 if else 并靠注释解释
第三方结构不稳定适配器让业务层到处处理字段差异
需要附加日志、缓存、权限代理包装后丢失原函数签名

总结

TypeScript 里的设计模式,关键不是“更像面向对象”,而是让变化面、稳定面和边界责任表达得更清楚。工厂控制创建,策略控制规则替换,适配器隔离外部差异,代理保留签名的同时叠加附加能力。只要模式引入后类型信息还在,团队就能真正享受到抽象带来的收益。

本系列导航:

延伸阅读: