TypeScript 协变与逆变 — 类型系统的方向感,一次讲透
连做五天题,今天换口味:理论课。它回答一个反直觉的问题——为什么"接收 Animal 的函数"能赋给"接收 Dog 的函数"变量,反过来却不行?这就是协变与逆变:类型兼容不只是"结构像不像",还有"方向对不对"。这是 TS 中高级面试的杀手锏,也是读懂事件系统、框架 API 设计的底层钥匙。
目录
一、从一个反直觉的现象开始
// 已知(D3 结构类型系统):
interface Animal { name: string }
interface Dog extends Animal { bark(): void }
// Dog 是 Animal 的子类型(多一个 bark)——子类型可以赋给父类型:
const a: Animal = { name: "旺财", bark() {} } as Dog; // ✅ 理所当然
// 现在看函数类型(⚠ 大脑预警):
let f1: (d: Dog) => void;
let f2: (a: Animal) => void;
f1 = f2; // ✅ ??接收 Animal 的函数,赋给了接收 Dog 的变量
f2 = f1; // ❌ 反过来不行?!
今天结束时你要能一句话讲清这两行为什么——不是背结论,是推导出来。
二、子类型回顾:兼容性的地基
2.1 子类型的定义
// "Dog 是 Animal 的子类型"(记作 Dog ⊑ Animal)意味着:
// 任何需要 Animal 的地方,给 Dog 都行(Dog 至少有 Animal 的一切)
// TS 的子类型判断基于结构(鸭子类型):
interface Point2D { x: number; y: number }
interface Point3D { x: number; y: number; z: number }
// Point3D ⊑ Point2D(多一个 z,结构上"至少"满足 Point2D)
const p2: Point2D = { x: 1, y: 2, z: 3 } as Point3D; // ✅
// 字面量 ⊑ 宽类型:
// "running" ⊑ string、65 ⊑ number
2.2 今天的问题:复杂类型的子类型关系怎么算?
// 单一类型的关系很直观。但"包装类型"呢?
// Dog ⊑ Animal,那么:
// 1. Dog[] 与 Animal[] 谁是谁的子类型?
// 2. Promise<Dog> 与 Promise<Animal>?
// 3. (x: Dog) => void 与 (x: Animal) => void?
// 4. Map<Dog, Cat> 与 Map<Animal, Cat>?
// 每个答案都可能不同——这就是"变型(Variance)"研究的对象:
// 复杂类型的子类型方向,如何由其类型参数的子类型方向决定
三、协变:方向一致
3.1 实验一:数组
declare const dogs: Dog[];
// 数组:Dog[] 可以赋给 Animal[]
let animals: Animal[] = dogs; // ✅ 协变!
// 直觉解释:
// 需要一组 Animal 的地方,给一组 Dog——每只 Dog 都"至少是"Animal,合理
// ⚠ 但注意:协变的数组有不安全的一面(读安全、写危险):
animals.push({ name: "一只不是 Dog 的动物" } as Animal);
// 编译通过!但 dogs 数组里混入了非 Dog——通过 animals 视图污染了 dogs
// (历史上 Java 数组协变导致的经典 bug,TS 数组在类型层同样如此,
// 但 TS 的日常用法里"读"远多于"写",协变是实用的妥协)
3.2 实验二:函数返回值
declare const makeDog: () => Dog;
// 返回 Dog 的函数,赋给"返回 Animal 的函数"变量:
let makeAnimal: () => Animal = makeDog; // ✅ 协变!
// 解释:调用方调用 makeAnimal() 拿到返回值当 Animal 用——
// 实际拿到的是 Dog,Dog 至少是 Animal,安全 ✅
3.3 协变的定义
协变(Covariance):
若 A ⊑ B,则 Wrap<A> ⊑ Wrap<B>(包装类型的子类型方向与参数一致)
TS 中的协变位置:
✅ 数组(只读场景)
✅ 函数返回值
✅ Promise<T>(只产出 T)
✅ Iterable<T>(只产出 T)
共同特征:T 只出现在"输出/产出"位置
四、逆变:方向相反
4.1 实验三:函数参数
declare const handleAnimal: (a: Animal) => void;
// 接收 Animal 的函数,赋给"接收 Dog 的函数"变量:
let handleDog: (d: Dog) => void = handleAnimal; // ✅ 逆变!(f1 = f2 那行)
// 解释(关键推导,逐步看):
// handleDog 的调用方期望:传给它一个 Dog,它能处理 ✅
// handleAnimal 实际能处理:一切 Animal(Dog 是 Animal,自然能处理)✅
// "能处理更宽输入的函数"顶替"只需处理窄输入的岗位"——绰绰有余
// 反过来(f2 = f1)为什么不行:
let bad: (a: Animal) => void = (d: Dog) => {}; // ❌
// bad 的调用方可能传任何 Animal(比如一只 Cat)
// 但实现只接受 Dog——传 Cat 进去就崩了 💥
4.2 逆变的定义
逆变(Contravariance):
若 A ⊑ B,则 Wrap<B> ⊑ Wrap<A>(包装类型的子类型方向与参数相反)
Dog ⊑ Animal
→ (x: Animal) => void ⊑ (x: Dog) => void ⭐ 方向翻转
TS 中的逆变位置:
✅ 函数参数(strictFunctionTypes 开启时)
共同特征:T 只出现在"输入/消费"位置
4.3 函数整体:返回协变 × 参数逆变
// 完整的函数类型兼容规则(假设 F1 要赋给 F2):
// F1 的参数要比 F2 的参数更宽(或相同)—— 逆变
// F1 的返回值要比 F2 的返回值更窄(或相同)—— 协变
// 示例:
type F1 = (a: Animal) => Dog; // 宽参数进,窄返回出
type F2 = (d: Dog) => Animal; // 窄参数进,宽返回出
declare const f1: F1;
let f2: F2 = f1; // ✅ F1 ⊑ F2(参数更宽 + 返回更窄,双满足)
五、口诀与推导:为什么这样才安全
5.1 记忆口诀
参数逆变,返回协变;
输入跟爹走,输出跟子走。
("爹" = 父类型/更宽的类型,"子" = 子类型/更窄的类型)
函数的参数位置要"更大宽",返回位置要"更小窄"——
整体形状像漏斗:宽进窄出。
5.2 安全性推导(李代桃僵的合格标准)
问题:什么函数能安全地顶替 (d: Dog) => Animal 这个岗位?
顶替者(李)要骗过调用方(我们只知道它是 (d: Dog) => Animal):
1. 调用方会传给它什么?—— Dog(可能带 bark 的)
→ 顶替者必须能处理 Dog 及一切可能的传入
→ 参数必须 ⊇ Dog(更宽才行)→ 参数逆变 ✅
2. 调用方会怎么用返回值?—— 当 Animal 用(只用 name)
→ 顶替者返回的东西必须"至少是 Animal"
→ 返回值必须 ⊑ Animal(更窄才行)→ 返回协变 ✅
结论:漏斗形状(宽进窄出)是唯一安全的顶替者。
5.3 一图流
Dog ⊑ Animal(子 ⊑ 父)
↓ 包装后的方向
┌─────────────────────────────────────────┐
│ 协变(方向不变) │
│ Dog[] ⊑ Animal[] │
│ () => Dog ⊑ () => Animal │
│ Promise<Dog> ⊑ Promise<Animal> │
│ (输出位置:拿子顶父,安全) │
├─────────────────────────────────────────┤
│ 逆变(方向翻转) │
│ (a: Animal) => void ⊑ (d: Dog) => void│
│ (输入位置:拿父顶子,安全) │
├─────────────────────────────────────────┤
│ 不变(方向必须相同) │
│ Storage<Dog> 与 Storage<Animal> 互不兼容│
│ (既读又写的位置:谁顶谁都出事) │
└─────────────────────────────────────────┘
六、不变:既协又逆的矛盾体
6.1 什么是不变(Invariant)
/**
* 既能读又能写 T 的容器:变型矛盾 → 不变
*/
interface Storage<T> {
get(): T; // T 在输出位置(要求协变)
set(v: T): void; // T 在输入位置(要求逆变)
}
declare const dogStorage: Storage<Dog>;
// 尝试 1:赋给 Storage<Animal>(协变方向)
let s1: Storage<Animal> = dogStorage;
s1.set({ name: "Cat" } as Animal);
// ⚠ 通过 s1 写入了 Cat → dogStorage.get() 拿到"是 Dog 类型"的 Cat 💥
// 尝试 2:赋给 Storage<SpecialDog>(逆变方向)同理可构造反例
// 结论:TS 对这种"读写兼备"的泛型接口判定为不变——
// Storage<Dog> 与 Storage<Animal> 互不兼容,只能精确匹配
6.2 你已经见过的不变
// 第 2 周的 Repository<T extends HasId>:
class Repository<T extends HasId> {
get(id: string): T | undefined { /* ... */ return undefined; } // 输出 T
add(item: T): void { /* ... */ } // 输入 T
update(id: string, patch: Partial<T>): T | undefined { /* ... */ return undefined; }
}
// T 既被输出又被输入 → Repository<Device> 与 Repository<Device2>(Device 的子类型)
// 互不兼容——这就是"不变"
// 实务影响:给 Repository<Device> 塞 Repository<SubDevice> 的实例?
// 编译报错。不是 bug,是变型保护
七、strictFunctionTypes 与双向协变
7.1 历史包袱
// tsconfig 的开关:
{
"compilerOptions": {
"strictFunctionTypes": true // strict: true 时默认开启
}
}
// 关闭时:函数参数是"双向协变"(bivariance)
// 即 (d: Dog) => void 和 (a: Animal) => void 互相可赋值(都通过)
// 为什么会有这么不严格的模式?
// —— 兼容旧代码。TS 2.6 之前只有双向协变,
// 大量存量代码依赖这个宽松行为
// 双向协变的隐患(重现第 4.1 节的反例):
function visitAnimals(callback: (a: Animal) => void) { /* ... */ }
declare const dogOnly: (d: Dog) => void;
// strictFunctionTypes: false 时:
visitAnimals(dogOnly); // 编译通过
// 运行时:visitAnimals 内部传了一只 Cat 给 dogOnly → dogOnly 调 cat.bark() 💥
7.2 方法 vs 函数属性的重要区别
// ⚠ TS 的一个著名不一致(为了兼容):
interface WithMethod {
handler(a: Animal): void; // 方法简写
}
interface WithProperty {
handler: (a: Animal) => void; // 函数属性
}
declare const dogMethod: { handler(d: Dog): void };
declare const dogProp: { handler: (d: Dog) => void };
// 方法简写:永远双向协变(不受 strictFunctionTypes 影响!)
let m: WithMethod = dogMethod; // ✅(即使不安全)
// 函数属性:受 strictFunctionTypes 控制(严格逆变)
let p: WithProperty = dogProp; // ❌ 严格模式下报错 ✅
// 启示:类型里"回调参数"用函数属性写法(handler: (a: X) => void)
// 能获得更严格的检查——这是写库的人必须知道的细节
八、in / out 注解(TS 4.7)
8.1 为什么要手动标注
// 编译器对未标注的泛型接口做"变型推断"——结构逐位置分析,费时且可能出错
// TS 4.7 起:显式标注变型,意图清晰 + 编译加速
interface Producer<out T> { // out = 协变声明
produce(): T; // 承诺:T 只出现在输出位置
}
interface Consumer<in T> { // in = 逆变声明
consume(value: T): void; // 承诺:T 只出现在输入位置
}
interface Box<in out T> { // in out = 不变的显式写法
get(): T;
set(v: T): void;
}
// 标注后编译器会校验你的承诺:
interface BadProducer<out T> {
consume(v: T): void; // ❌ 编译错误:out 声明的 T 不能出现在输入位置
}
8.2 标注的实务价值
// 1. 编译提速:大型泛型接口的变型推断成本高,标注后跳过推断
// 2. API 文档化:使用者一眼看出类型参数怎么用
// 3. 防呆:写错位置编译器直接指出
// Vue/React 源码里已大量使用(emit 的事件类型是 out、props 是 in)
九、工业实战场景全覆盖
9.1 场景一:事件处理器的注册安全
/**
* 大屏事件总线:分发的是具体事件类型(子类型)
* 注册的处理器参数可以更宽(逆变保护)
*/
interface BaseEvent { ts: number }
interface DeviceClickEvent extends BaseEvent { deviceId: string; x: number; y: number }
interface AlertEvent extends BaseEvent { level: 1 | 2 | 3 }
type Handler<E> = (e: E) => void;
class Emitter<E> {
private handlers: Handler<E>[] = [];
on(h: Handler<E>): void { this.handlers.push(h); }
emit(e: E): void { this.handlers.forEach(h => h(e)); }
}
declare const deviceEmitter: Emitter<DeviceClickEvent>;
// ✅ 合法:处理器只用了 BaseEvent 的能力(参数逆变)
deviceEmitter.on((e: BaseEvent) => {
console.log("事件时间戳", e.ts); // 宽参数安全顶替
});
// ❌ 非法:处理器想要"更窄"的事件(可能访问不存在的字段)
deviceEmitter.on((e: DeviceClickEvent & { extra: string }) => {
console.log(e.extra); // 分发方不保证 extra——运行时 undefined 💥
});
// 逆变规则在编译期拦住了这个 bug ⭐
9.2 场景二:中间件管道的类型接力
/**
* Express/Koa 风格中间件:每个中间件扩展 context
* 逆变 + 交叉类型 = 类型安全的管道接力
*/
interface BaseCtx { req: Request; res: Response }
interface AuthCtx extends BaseCtx { user: { id: string } }
interface LogCtx extends AuthCtx { traceId: string }
type Middleware<In extends BaseCtx> = (ctx: In) => void;
// 中间件组合器:
function use<In extends BaseCtx>(m: Middleware<In>): void {}
// 需求:管道后段(有 user 的 Ctx)的中间件能注册到前段(只有 Base 的管道)吗?
const authMiddleware: Middleware<AuthCtx> = (ctx) => {
ctx.user; // 需要 user
};
// use<AuthCtx> 的管道承诺提供 AuthCtx——authMiddleware 收到的满足要求 ✅
// 但 use<BaseCtx> 的管道不保证 user——注册 authMiddleware 会被类型拒绝:
// use<BaseCtx>(authMiddleware);
// ❌:Middleware<AuthCtx> 不能赋给 Middleware<BaseCtx>
// 恰恰因为参数逆变:Middleware<BaseCtx> ⊑ Middleware<AuthCtx>(方向反过来)
// 结论:类型系统强制你"中间件的依赖必须先注册"——管道顺序的类型化 ⭐
9.3 场景三:观察者模式的数据流方向
/**
* 遥测数据的订阅:生产者协变 + 订阅者逆变
*/
interface Telemetry { deviceId: string; ts: number }
interface TempTelemetry extends Telemetry { temp: number }
// 生产者侧(协变):温度生产者可以喂给通用订阅管道
interface Producer<out T> { subscribe(h: (v: T) => void): void }
declare const tempProducer: Producer<TempTelemetry>;
let anyProducer: Producer<Telemetry> = tempProducer; // ✅ 协变
// 订阅者侧(逆变):通用订阅者可以注册到温度管道
const logAny: (v: Telemetry) => void = v => console.log(v.deviceId);
tempProducer.subscribe(logAny); // ✅ 逆变
// 组合结论:
// Producer<Temp> → Producer<Telemetry>(产出更具体的,安全)
// (v: Telemetry) => void → (v: Temp) => void 的位置(消费更宽泛的,安全)
// 数据流向一以贯之:具体的流向宽泛的出口 ⭐
9.4 场景四:给第 2 周的 Repository 标注变型
// 动手分析(今日练习的种子):
class Repository<T extends HasId> {
get(id: string): T | undefined { return undefined; } // T 输出 → out?
add(item: T): void {} // T 输入 → in?
update(id: string, patch: Partial<T>): T | undefined { return undefined; } // 双向
}
// 逐位置统计:
// get:输出
// add:输入
// update:输入 + 输出
// → T 同时出现在输入和输出位置 → 不变(invariant)
// 试着只标注 out 或 in 都会编译报错——这个亲手实验就是最好的作业
十、类比记忆:快递员 vs 海关
函数 = 岗位上的快递员
┌─────────────────────────────────────────┐
│ 岗位说明书(函数类型): │
│ "接收 Dog 包裹,产出 Animal 报告" │
│ │
│ 谁能顶班? │
│ 能接收更多种类包裹的(Animal 都收)✅ │
│ 产出更精细报告的(Dog 报告也算 Animal │
│ 报告)✅ │
│ —— 漏斗:宽进窄出 │
│ │
│ 谁不能顶班? │
│ 只收 SpecialDog 的(收到普通 Dog 会 │
│ 拒收)❌ │
│ 只能产出 Cat 报告的(岗位要 Animal)❌ │
└─────────────────────────────────────────┘
变型 = 海关的通行规则
┌─────────────────────────────────────────┐
│ 协变关卡(输出位): │
│ 子类型的货可以按父类型报关 ✅ │
│ 逆变关卡(输入位): │
│ 父类型的通行证可以进子类型的门 ✅ │
│ 不变关卡(读写位): │
│ 证件必须一模一样,差一点都退回 ⚠ │
└─────────────────────────────────────────┘
十一、常见坑点与最佳实践
坑点 1:用直觉判断复杂泛型的兼容性
// 直觉:Emitter<Dog> 和 Emitter<Animal> 差不多吧?
// 现实:取决于 Emitter 内部 T 的位置——
// 只 emit(输出)→ 协变
// 只 on(参数里有 T)→ 逆变
// 既 on 又 emit → 不变
// 结论:判断前先看接口定义里 T 出现在哪些位置
坑点 2:方法简写的宽松陷阱
// 团队代码规范建议(写库/公共类型时):
interface Listener {
onEvent(e: Event): void; // ⚠ 方法简写:双向协变(不安全但宽松)
}
interface ListenerStrict {
onEvent: (e: Event) => void; // ✅ 函数属性:严格逆变(受 strictFunctionTypes)
}
// 回调字段一律用"函数属性"写法——这是库作者的自我修养
坑点 3:以为 strict 关掉 strictFunctionTypes 没影响
// 影响真实存在:所有函数参数位置的检查都退化为双向协变
// 排查兼容性 bug 时,先确认 tsconfig 的这两个字段:
// strict / strictFunctionTypes 的实际值
// (npx tsc --showConfig 可以看最终生效配置)
坑点 4:in/out 标注与实际用法不符
interface Bad<out T> {
both(v: T): T; // ❌ 声明 out 但 v: T 是输入位
}
// 编译器会报错——这是好事
// 反过来:标注后接口的变型被"锁死",未来加方法受约束(设计时想清楚)
坑点 5:拿 any 绕过变型检查
// 变型报错的场景,用 as any 强转 = 埋雷
// 正确姿势:
// 1. 先确认是不是真的需要这个赋值(往往是设计问题)
// 2. 参数放宽 / 返回值收窄(顺着漏斗方向改)
// 3. 实在要转换,用 as unknown as 目标类型 并注释原因
最佳实践清单
判断兼容三步:找 T 的位置 → 分类(输入/输出/双向)→ 套口诀
回调字段用函数属性写法(严格逆变检查)
公共泛型接口考虑 in/out 标注(文档 + 提速 + 防呆)
变型报错别用 any 硬闯——先怀疑设计
给团队讲一次"漏斗规则"——讲得清 = 真懂了
十二、自测挑战
Q1:协变和逆变的定义各是什么?各举一个 TS 中的协变/逆变位置。
Q2:f1 = f2 与 f2 = f1(第一节的例子)哪个合法?用"漏斗"推导解释。
Q3:为什么函数参数逆变才是安全的?构造 f2 = f1(反向)的运行时崩溃反例。
Q4:Storage<T>(get + set)为什么是不变?构造两个方向的污染反例。
Q5:strictFunctionTypes: false 时函数参数是什么变型?这个设计为什么存在?
Q6:方法简写 handler(a: X): void 和函数属性 handler: (a: X) => void 在变型检查上有什么区别?
Q7:Producer<out T> 里的 out 承诺了什么?违反承诺会发生什么?
Q8:第 2 周的 Repository<T> 是什么变型?逐位置分析。
Q9:事件总线的注册中,"处理器参数比事件类型宽"为什么安全?
Q10:中间件管道的例子里,类型系统如何强制"依赖先注册"?
十三、总结与知识图谱
协变与逆变(Day 20)
│
├── 地基
│ └── 子类型关系(结构化:多属性者 ⊑ 少属性者)
│
├── 三种变型
│ ├── 协变(方向一致):数组 / 返回值 / Promise
│ │ —— T 在输出位:"拿子顶父,安全"
│ ├── 逆变(方向翻转):函数参数
│ │ —— T 在输入位:"拿父顶子,安全"
│ └── 不变(必须相同):Storage(get+set)
│ —— T 在读写位:"谁顶谁都出事"
│
├── 口诀
│ └── 参数逆变返回协变 / 宽进窄出漏斗 / 输入跟爹输出跟子
│
├── 配置与语法
│ ├── strictFunctionTypes(关=双向协变,历史兼容)
│ ├── 方法简写 vs 函数属性(检查严格度不同)
│ └── in / out 注解(TS 4.7:承诺 + 校验 + 提速)
│
└── 工业落地
├── 事件处理器注册(逆变保护)
├── 中间件管道(注册顺序类型化)
├── 观察者模式(生产协变 + 订阅逆变)
└── Repository 变型分析(不变)
一句话总结:类型兼容看两件事——结构够不够(子类型)+ 方向对不对(变型);输出位跟子类型同向(协变),输入位反向(逆变),读写兼备就只能不变。函数是漏斗:宽进窄出才安全。
延伸阅读
下一步
明天(Day 21)是第 3 周 BOSS 战:10 道 medium 精选冲刺(含本周终极挑战 Permutation)+ 周总结博客。今天攒的理论(变型)也将在题目复盘中派上用场。
协变逆变学完的标志:看到函数赋值,你的眼睛会自动扫描"参数谁宽、返回谁窄"。
每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 6 天,理论深水区通关!