这节在地图里是"类型用在结构层面"的起点。设计模式本质是"对象之间如何协作的固定套路",而 TS 的类型系统能在编译期保证这些套路不被用错。我们讲三个高频模式,并点出 TS 比传统 OOP 语言实现它们轻在哪。
传统写法一堆 if/else 判断策略类型,TS 用可辨识联合让编译器逼你处理每种情况。
type Discount = | { kind: "none" } | { kind: "percent"; rate: number } | { kind: "fixed"; amount: number }; function calc(price: number, d: Discount): number { switch (d.kind) { case "none": return price; case "percent": return price * (1 - d.rate); case "fixed": return Math.max(0, price - d.amount); } } calc(100, { kind: "percent", rate: 0.2 }); // 80
输入输出:传入不同 kind,编译器在 switch 内收窄出对应字段(如 d.rate)。新增一种折扣忘了处理,加 never 兜底(见 1.1)会编译失败。这里类型是"策略完备性"的保证,而不只是文档。
interface Button { render(): string; } interface WindowsButton implements Button { render() { return "Win按钮"; } } interface MacButton implements Button { render() { return "Mac按钮"; } } function createButton(os: "win" | "mac"): Button { return os === "win" ? new WindowsButton() : new MacButton(); } const b = createButton("mac"); // b: Button,render 可用
背景:按环境造不同产品。操作:工厂返回公共接口 Button。结果:调用方只依赖接口,不关心具体类。解读:implements Button 把"这个类承诺满足按钮契约"钉死(第三章),重构漏方法编译即失败。
type Listener<T> = (payload: T) => void; class Emitter<Events extends Record<string, unknown>> { private map: { [K in keyof Events]?: Listener<Events[K]>[] } = {}; on<K extends keyof Events>(k: K, fn: Listener<Events[K]>) { (this.map[k] ||= []).push(fn); } emit<K extends keyof Events>(k: K, payload: Events[K]) { this.map[k]?.forEach((fn) => fn(payload)); } } interface AppEvents { login: { userId: number }; logout: void; } const bus = new Emitter<AppEvents>(); bus.on("login", (p) => console.log(p.userId)); // p 精确为 {userId:number} bus.emit("login", { userId: 1 });
这张图把策略模式中"类型如何逼出完备处理"画出来:

Java 写策略要一堆接口和类文件,TS 用联合类型 + 收窄就能表达"几种行为",无需为每个策略建类。代价是行为无状态、无方法复用时联合更合适;行为复杂、需状态时仍用类层次。这是 2.3 讲过的"联合 vs 继承"取舍在模式层面的体现。
模式是解决问题的工具,不是目标。我们见过为"将来可能扩展"提前上抽象工厂,结果三年没第二个产品,反而多了两层没用的间接。经验:等出现第二个真正不同的实现时再抽模式,第一个就直接写,类型标注已足够保安全。
传统装饰器靠继承套娃,TS 用交叉类型(&)把多个能力在类型层拼起来,运行时不引入类层次。
interface Loggable { log(): void; } interface Cacheable { cache(): void; } // 交叉类型:同时具两种能力 type Service = Loggable & Cacheable; const s: Service = { log() { console.log("log"); }, cache() { console.log("cache"); }, }; s.log(); s.cache(); // 两者都可用,缺一个就编译错
输入输出:Service 要求同时实现 log 与 cache,漏写任一个方法立即标红。交叉类型比"类 A 继承 B 再继承 C"轻——它描述的是"能力的集合",不绑定继承链,重构时增删能力只改类型不动物件结构。
策略/状态机处理里,漏处理一种情况是常态 bug。用 never 兜底让编译器替你盯着。
type Status = "a" | "b" | "c"; function handle(s: Status): string { switch (s) { case "a": return "A"; case "b": return "B"; case "c": return "C"; default: { const _exhaustive: never = s; // 若新增 "d" 未处理,这里会编译报错 return _exhaustive; } } }
背景:以后有人给 Status 加 "d" 却忘在 switch 加分支。操作:default 里把 s 赋给 never。结果:新增未覆盖的状态会让 never 赋值失败,编译期拦截。解读:这是"穷尽性检查"的经典手法,把"漏分支"从运行时 bug 变成编译期错误——详见第一章契约判定。
单例要保证"全局只有一个实例且类型正确",用私有构造 + 静态工厂,并用类型约束返回类型。
class ConfigStore { private static instance: ConfigStore | null = null; readonly settings: Record<string, string>; private constructor() { this.settings = { region: "cn", lang: "zh" }; } static get(): ConfigStore { if (!ConfigStore.instance) ConfigStore.instance = new ConfigStore(); return ConfigStore.instance; } } const a = ConfigStore.get(); const b = ConfigStore.get(); // 两者是同一实例,且 a.settings 有精确类型
背景:全局配置/连接池常需单例。操作:私有构造阻止 new,静态 get 返回 ConfigStore。结果:外部无法随意创建,类型始终正确。解读:private constructor 是运行期也生效的真私有,比 private 字段更彻底,和第三章讲的一致——类型契约此处也是运行期边界。
命令模式把"操作"封装成对象。用泛型让"每条命令的入参/结果"都在类型层签好。
interface Command<TArgs, TResult> { execute(args: TArgs): TResult; } class LoginCmd implements Command<{ user: string; pwd: string }, boolean> { execute(args: { user: string; pwd: string }): boolean { return args.user.length > 0; } } const cmd = new LoginCmd(); const ok = cmd.execute({ user: "a", pwd: "x" }); // ok: boolean // cmd.execute({ user: "a" }); // 报错:缺 pwd,契约生效
背景:想把"操作"当数据传递(队列、撤销重做)。操作:Command<TArgs, TResult> 约束签名。结果:每条命令的参数与返回值都被检查。解读:泛型让"命令接口"从"无类型黑盒"变成"带形状的白盒",调用方无需看实现就知道怎么传、得到什么。
两个库接口不一致时,适配器在类型层用一个兼容接口盖住差异,调用方只认适配接口。
interface LegacyLogger { write(msg: string): void; } interface NewLogger { log(level: string, msg: string): void; } // 适配器:让老接口套上新契约 const adapt = (l: LegacyLogger): NewLogger => ({ log: (level, msg) => l.write(`[${level}] ${msg}`), }); const logger = adapt({ write: (m) => console.log(m) }); logger.log("info", "启动"); // 调用方只用 NewLogger 签名
背景:迁移期新旧库并存,调用方不想改。操作:写一层适配器在类型上对齐。结果:调用方代码面向新接口,底层可渐进替换。解读:适配器是"边界处设卡"的又一例——在接缝处用类型统一契约,内部差异被封住,符合全册主线。
⚠️ 别为了"看起来专业"提前套设计模式。多一层抽象就多一层理解成本,Ts 的类型标注本身已提供不少保护,没第二个真实变体前,直接写往往更清楚。
💡 实现"有限几种行为"优先用联合+收窄(如策略、状态机),比建一堆类文件轻;只有当行为带状态、需共享方法时,才升级到类层次。