先记住这个答案
TypeScript 会依据泛型结构中类型参数的使用方式推断方差。只产生 T 的接口通常体现协变,消费 T 的函数属性通常体现逆变;同时读写可能形成更严格关系,方法语法又有兼容例外。方差推断与一次调用中推断 T 是什么不是同一个问题。in、out 等注解用于少数确有需要的类型设计与诊断场景,不能用来任意改写结构类型行为;它们只在相应的实例化比较中发挥作用,不应被当成强制所有比较按某种方向运行的开关。
- 先区分产生值与消费值对应的使用方向
- 方差关系不同于本次调用选择哪个 T
- 通常依赖结构推断,不随意添加 in/out 注解
为什么生产与消费方向相反
能够生产点击事件的对象,输出包含通用事件需要的全部字段,因此可以服务只需要通用事件的使用者。能够消费任意通用事件的函数,也能接收点击事件;反过来,只会消费点击事件的函数却不能保证处理其他事件。
示例用函数属性明确消费位置,避免方法兼容例外混入基础方向。两个安全赋值分别展示生产者协变与消费者逆变,错误赋值则保留预期诊断。解释时沿实际读取与调用路径走一遍,比背诵输入逆变输出协变更容易看出原因。
type EventBase = { kind: string };
type ClickEvent = EventBase & { x: number };
type Producer<T> = { make: () => T };
type Consumer<T> = { consume: (value: T) => void };
const clicks: Producer<ClickEvent> = { make: () => ({ kind: 'click', x: 1 }) };
const events: Producer<EventBase> = clicks;
const acceptAny: Consumer<EventBase> = { consume: value => { void value.kind; } };
const acceptClick: Consumer<ClickEvent> = acceptAny;
// @ts-expect-error 只能消费点击事件的接口不能承诺消费所有事件
const unsafe: Consumer<EventBase> = acceptClick;生产点击事件可以满足基础事件读取,通用消费者可以服务点击调用方。最后的静态赋值按目标接口承诺检查,即使某个运行对象碰巧来自更宽消费者,也不能把窄契约任意扩张。
同时出现多个位置时不要机械套规则
类型参数可能嵌套在回调、返回对象或其他泛型中,方向会受到组合结构影响。既提供读取又允许任意写入的状态容器,需要满足双方要求,通常比只读生产者更难替换。若使用方法参数,还要额外考虑 TypeScript 的历史兼容行为。
这里讨论的是两个泛型实例之间的关系,例如 Producer<ClickEvent> 与 Producer<EventBase>。函数调用推断出 T 为字符串还是数字,则是在寻找本次参数的具体选择。两者虽然都使用推断一词,但证据和目标不同,不应混成一个规则。
为什么通常不手写方差注解
编译器大多能从结构中得出需要的方差信息。只有遇到明确的复杂循环类型、诊断或经分析确认的性能问题时,才值得考虑注解。注解必须与实际结构关系相容,不能把不安全的消费者强行声明成生产者来绕过报错。
官方文档还指出注解只影响特定实例化比较,不改变任意结构比较。因此它不是实现名义类型或全局强制不变性的工具。维护公共类型时,应优先简化成员关系、使用正确函数语法并验证消费端例子,避免为了显得高级而增加难解释的标记。
容易答错的地方
- in/out 可以强制任意对象按指定方向兼容
- 注解有特定适用范围,不会重写所有结构比较行为;它应符合实际成员关系,不能拿来制造与结构不一致的类型安全承诺。
- 方差推断就是自动猜出调用参数的具体类型
- 方差关心替换类型参数后整个容器的兼容方向,调用推断关心某次 T 取什么;理解时要分别列出比较对象与可用证据。
面试官还会怎么问?
消费者为什么适合用函数属性举例?
严格模式对函数属性参数执行相应兼容检查,而方法声明存在双向兼容例外;用函数属性能更直接展示真实输入能力方向。
同时读写 T 的接口一定可以协变吗?
不能只看返回位置,写入位置也会施加要求;应分析所有成员和嵌套回调,验证较宽引用是否可能写入较窄类型不接受的值。
普通业务代码需要到处标注 in/out 吗?
通常不需要,结构推断已经覆盖多数情况;只有能够说明具体问题、注解含义和验证结果时才应加入,避免增加维护负担。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。