映射类型、条件类型、模板字面量类型与 infer

当多个类型只有一处规律性的差别时,手工维护很容易漏改。映射类型和条件类型可以表达这种规律。下面先看简单的输入与结果,再逐步读 infer 和模板字面量;如果一种写法已经难以向同事解释,就值得考虑更直接的方案。

可以先手写两个具体类型,找出它们之间重复的规则,再把规则抽成类型运算。如果一个普通联合类型已经足够清楚,就没有必要换成复杂递归。高级写法的价值在于减少维护,而不是增加阅读难度。

1. 映射类型

TypeScript
type Flags<T> = { [K in keyof T]: boolean } type Mutable<T> = { -readonly [K in keyof T]-?: T[K] }

映射类型遍历属性键,可用 readonly、? 及 - 增减修饰符。

键重映射

TypeScript
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] } type Model = { name: string; age: number } type ModelGetters = Getters<Model> // { getName(): string; getAge(): number }

2. 条件类型

TypeScript
type ElementOf<T> = T extends readonly (infer Item)[] ? Item : T type A = ElementOf<string[]> // string type B = ElementOf<number> // number

条件类型判断的是类型可赋值关系,不是运行时 if。

3. infer 提取结构

TypeScript
type AsyncResult<T> = T extends (...args: never[]) => Promise<infer R> ? R : never type Head<T> = T extends readonly [infer First, ...unknown[]] ? First : never

infer 只能出现在条件类型的 extends 模式中,用来给待提取部分命名。

4. 分布式条件类型

TypeScript
type ToArray<T> = T extends unknown ? T[] : never type Distributed = ToArray<string | number> // string[] | number[] type ToSingleArray<T> = [T] extends [unknown] ? T[] : never type NotDistributed = ToSingleArray<string | number> // (string | number)[]

当被检查位置是裸类型参数时,条件类型会对联合逐项分布;用元组包裹两侧可关闭分布。

5. 模板字面量类型

TypeScript
type Entity = "user" | "order" type Action = "created" | "deleted" type DomainEvent = `${Entity}:${Action}` const event: DomainEvent = "user:created"

它适合路由、事件名、CSS 尺寸和对象派生 API。超大联合会造成类型爆炸,官方建议对非常大的字符串集合使用预生成代码。

6. 类型安全事件总线

TypeScript
type Events = { "user:created": { id: string } "order:paid": { orderId: string; amount: number } } class EventBus<TEvents extends Record<string, unknown>> { private readonly handlers = new Map<keyof TEvents, Set<(value: unknown) => void>>() on<K extends keyof TEvents>(key: K, handler: (event: TEvents[K]) => void): () => void { const set = this.handlers.get(key) ?? new Set() set.add(handler as (value: unknown) => void) this.handlers.set(key, set) return () => set.delete(handler as (value: unknown) => void) } emit(...args: { [K in keyof TEvents]: [key: K, event: TEvents[K]] }[keyof TEvents]): void { const [key, event] = args this.handlers.get(key)?.forEach((handler) => handler(event)) } }

实现内部因异构集合需要受控断言。emit 使用按键生成的元组联合,保持单次调用的名称与负载关联;只写泛型 K 和 TEvents[K] 两个独立参数时,K 若是联合,可能接受不匹配组合。真实系统还需处理异常隔离、异步顺序和退订生命周期。

例如 key 的类型是 “user:created” | “order:paid”,就不能在尚未收窄时随意传入用户负载,因为运行时 key 也可能是订单事件。调用者应先收窄,或把名称和负载一起保存为有对应关系的元组。这个总线是同步、内存内的教学模型,监听器抛错会影响当前分发,也没有持久化和跨进程投递保证。

7. 递归类型的性能与终止

TypeScript
type JsonValue = | string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]

递归类型应有明确基本分支。深层条件、巨大联合和互相递归会拖慢编辑器甚至触及实例化深度限制。先测量类型检查耗时,再决定是否简化或代码生成。

8. 类型级测试

TypeScript
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false type Expect<T extends true> = T type Case = Expect<Equal<ElementOf<readonly string[]>, string>>

库项目可使用专门的类型测试工具,但核心是把预期兼容与预期错误都纳入 CI。

参考:Mapped Types、Conditional Types、Template Literal Types。