很多人一提到“设计模式 + 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 里的设计模式,关键不是“更像面向对象”,而是让变化面、稳定面和边界责任表达得更清楚。工厂控制创建,策略控制规则替换,适配器隔离外部差异,代理保留签名的同时叠加附加能力。只要模式引入后类型信息还在,团队就能真正享受到抽象带来的收益。
本系列导航:
- 如果你想先稳住抽象层对外承诺,接着看 TypeScript 公共 API 设计
- 若你要把模式继续落到测试和用例上,再看 TypeScript 测试数据构建
- 如果你接下来要处理业务状态与错误流,再读 TypeScript 表单与错误状态建模
延伸阅读:


