在业务开发中,我们经常遇到根据某个类型字段执行不同逻辑的场景,比如订单状态处理、支付渠道分发、消息推送等。最直观的写法是if-else或switch,但随着业务类型不断增多,判断语句会越积越长,最终变成几百行的巨型函数。本文将介绍如何借助TypeScript的类型系统实现策略模式,把这些分散的分支逻辑收敛为独立、可扩展的策略单元。

一、if-else堆积带来的问题
假设我们有一个消息通知系统,需要根据不同的通知渠道发送消息。最原始的写法如下:
function sendMessage(channel: string, content: string) {
if (channel === 'email') {
// 邮件发送逻辑
console.log(`通过邮件发送: ${content}`);
} else if (channel === 'sms') {
// 短信发送逻辑
console.log(`通过短信发送: ${content}`);
} else if (channel === 'wechat') {
// 微信发送逻辑
console.log(`通过微信发送: ${content}`);
} else if (channel === 'dingtalk') {
// 钉钉发送逻辑
console.log(`通过钉钉发送: ${content}`);
} else {
throw new Error(`不支持的渠道: ${channel}`);
}
}
这种写法在渠道少的时候看似简洁,但随着业务扩展,问题会逐渐暴露。首先是违反开闭原则,每次新增一个渠道都要修改这个函数,任何改动都可能影响已有逻辑,回归测试的范围被迫扩大。其次是代码可读性下降,当分支达到十几个时,很难快速定位某个渠道的具体实现。
更隐蔽的问题是类型安全问题。上面的channel参数被声明为string,这意味着调用方可以传入任意字符串,编译器无法帮我们拦截拼写错误,比如把email误写成emial,只有在运行时才会抛出异常。此外,每个分支内部的逻辑也可能越来越复杂,单元测试时无法单独测试某个渠道,只能通过构造不同的入参间接覆盖,测试粒度非常粗糙。
二、策略模式的核心思想与TypeScript接口定义
策略模式的核心是把一族算法各自封装成独立的类或函数,让它们可以互相替换,客户端代码只依赖统一的抽象接口,而不关心具体实现。在TypeScript中,我们通常用一个接口来约束策略的形状,再用类型字面量联合类型来约束渠道标识。
首先定义渠道类型和策略接口:
// 使用字面量联合类型代替宽泛的 string,拼写错误在编译期就能发现
type Channel = 'email' | 'sms' | 'wechat' | 'dingtalk';
// 策略接口:约定每个策略必须实现的方法
interface MessageStrategy {
send(content: string): Promise<void>;
}
// 各渠道的具体实现
class EmailStrategy implements MessageStrategy {
async send(content: string): Promise<void> {
console.log(`通过邮件发送: ${content}`);
}
}
class SmsStrategy implements MessageStrategy {
async send(content: string): Promise<void> {
console.log(`通过短信发送: ${content}`);
}
}
这种写法的好处是每个策略都可以独立开发、独立测试。如果要给邮件渠道增加重试逻辑,只需要修改EmailStrategy,其他渠道完全不受影响。同时,如果某个类没有完整实现MessageStrategy接口,TypeScript编译器会立即报错,避免了接口实现不一致的问题。
值得注意的是,策略模式并不强制要求使用class。在函数式风格中,一个策略也可以只是一个符合签名的函数。由于TypeScript的函数类型本身就是一等公民,我们可以把接口定义简化为函数类型别名,代码会更加轻量:
// 函数式策略:一个策略就是一个函数
type SendFn = (content: string) => Promise<void>;
const emailStrategy: SendFn = async (content) => {
console.log(`通过邮件发送: ${content}`);
};
三、用映射对象注册策略,彻底消除分支判断
有了统一的策略接口后,下一步是解决分发问题。传统OOP写法会在上下文类中持有策略引用并通过构造函数注入,而更简洁的TypeScript做法是使用映射对象(对象字典)作为注册表,用类型约束保证注册表的完整性。
type Channel = 'email' | 'sms' | 'wechat' | 'dingtalk';
interface MessageStrategy {
send(content: string): Promise<void>;
}
const strategies: Record<Channel, MessageStrategy> = {
email: {
async send(content) {
console.log(`通过邮件发送: ${content}`);
}
},
sms: {
async send(content) {
console.log(`通过短信发送: ${content}`);
}
},
wechat: {
async send(content) {
console.log(`通过微信发送: ${content}`);
}
},
dingtalk: {
async send(content) {
console.log(`通过钉钉发送: ${content}`);
}
}
};
// 分发函数:一行代码完成所有类型判断
async function sendMessage(channel: Channel, content: string) {
const strategy = strategies[channel];
await strategy.send(content);
}
这段代码的关键在于Record<Channel, MessageStrategy>这个类型声明。它要求注册表必须覆盖Channel联合类型中的每一个成员,少写一个键编译器都会报错。反过来,如果未来新增了'webhook'渠道,只要把它加进Channel类型,TypeScript会立刻提示注册表缺少webhook的实现,实现了编译期的完整性检查,这正是类型驱动开发的典型应用。
分发函数sendMessage中没有一行if-else,逻辑只剩查表和调用两步。新增渠道时只需要两步操作:扩展Channel类型、在注册表中添加实现,完全符合开闭原则。如果希望支持运行时动态注册策略,可以把注册表改为Map并提供register方法:
class StrategyRegistry {
private strategies = new Map<Channel, MessageStrategy>();
register(channel: Channel, strategy: MessageStrategy): void {
this.strategies.set(channel, strategy);
}
get(channel: Channel): MessageStrategy {
const strategy = this.strategies.get(channel);
if (!strategy) {
throw new Error(`渠道 ${channel} 尚未注册策略`);
}
return strategy;
}
}
注册表模式特别适合插件化架构,比如各个业务模块在初始化时注册自己的处理策略,核心调度代码对具体业务一无所知。
四、利用判别联合与类型收窄处理复杂分支
有些场景下,不同分支的入参结构也不同,比如不同支付方式的请求参数差异很大。这时单纯映射到函数并不够,需要结合TypeScript的判别联合类型和类型收窄特性,让编译器自动推断出每个分支中参数的确切类型。
// 判别联合:kind 字段用于区分不同的支付请求
type PayRequest =
| { kind: 'alipay'; orderId: string; buyerId: string }
| { kind: 'wechat'; orderId: string; openId: string }
| { kind: 'bank'; orderId: string; bankCardNo: string; cvv: string };
type PayHandler = {
alipay: (req: { orderId: string; buyerId: string }) => Promise<void>;
wechat: (req: { orderId: string; openId: string }) => Promise<void>;
bank: (req: { orderId: string; bankCardNo: string; cvv: string }) => Promise<void>;
};
const handlers: PayHandler = {
alipay: async (req) => {
// 这里 req 的类型被自动推断,包含 orderId 和 buyerId
console.log('支付宝支付', req.orderId);
},
wechat: async (req) => {
console.log('微信支付', req.orderId);
},
bank: async (req) => {
console.log('银行卡支付', req.bankCardNo);
}
};
async function pay(request: PayRequest) {
// 通过 kind 字段查表,参数类型自动匹配,无需任何类型断言
await handlers[request.kind](request);
}
这个方案最精妙的地方在于handlers[request.kind](request)这一行。由于request是判别联合类型,通过request.kind索引后,TypeScript会自动把request收窄到对应的成员类型,传给处理函数的参数类型完全正确,不需要写任何as断言。整个分发过程零类型判断、零运行时开销,且新增支付方式时编译器会强制补全处理函数。
相比之下,如果用传统switch实现同样的逻辑,虽然也能通过类型收窄保证类型安全,但所有逻辑仍然集中在一个函数里,无法做到策略的独立测试和按需加载。判别联合加映射对象的组合,兼顾了类型安全和架构灵活性。
五、方案对比与选型建议
上述几种实现方式各有适用场景,可以通过下表做对比:
| 方案 | 类型安全 | 扩展方式 | 适用场景 |
|---|---|---|---|
| if-else / switch | 依赖人工维护 | 修改原函数 | 分支极少且稳定 |
| class策略 + 接口 | 编译期检查 | 新增类并注册 | 策略有状态或依赖注入 |
| Record映射对象 | 编译期强制完整 | 扩展类型加键值 | 无状态、轻量分发 |
| 判别联合 + 映射 | 参数类型自动收窄 | 扩展联合成员 | 各分支入参结构不同 |
实际选型时可以遵循几个原则:如果分支逻辑只有两三个且几乎不会变化,直接写if-else反而更直观,不必过度设计;如果策略本身有状态、需要注入外部依赖(如配置、日志),class形式的策略更合适;如果只是纯函数式的分发,Record映射对象是最简洁的选择;如果不同分支的参数结构不一致,判别联合是唯一能同时保证安全和简洁的方案。
最后提醒一点,策略模式的收益不仅体现在消除if-else本身,更体现在架构层面:每个策略是独立单元,可以单独编写单元测试;策略之间彼此隔离,修改风险被限制在单个文件内;依赖关系从隐式的分支耦合变成显式的接口契约。当你在代码审查中看到超过五个分支的类型判断时,就可以考虑动手重构了。TypeScript的类型系统会把重构过程中遗漏的地方一一标红,让这次重构安全落地。
TypeScript策略模式if-else重构修改时间:2026-09-02 02:22:43