【TS】day20-variance

作者:mario 发布时间: 2026-08-31 阅读量:6 评论数:0

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 目标类型 并注释原因

最佳实践清单

  1. 判断兼容三步:找 T 的位置 → 分类(输入/输出/双向)→ 套口诀

  2. 回调字段用函数属性写法(严格逆变检查)

  3. 公共泛型接口考虑 in/out 标注(文档 + 提速 + 防呆)

  4. 变型报错别用 any 硬闯——先怀疑设计

  5. 给团队讲一次"漏斗规则"——讲得清 = 真懂了


十二、自测挑战

Q1:协变和逆变的定义各是什么?各举一个 TS 中的协变/逆变位置。

Q2f1 = f2f2 = f1(第一节的例子)哪个合法?用"漏斗"推导解释。

Q3:为什么函数参数逆变才是安全的?构造 f2 = f1(反向)的运行时崩溃反例。

Q4Storage<T>(get + set)为什么是不变?构造两个方向的污染反例。

Q5strictFunctionTypes: false 时函数参数是什么变型?这个设计为什么存在?

Q6:方法简写 handler(a: X): void 和函数属性 handler: (a: X) => void 在变型检查上有什么区别?

Q7Producer<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 变型分析(不变)

一句话总结:类型兼容看两件事——结构够不够(子类型)+ 方向对不对(变型);输出位跟子类型同向(协变),输入位反向(逆变),读写兼备就只能不变。函数是漏斗:宽进窄出才安全。


延伸阅读

资源

说明

strictFunctionTypes - TS 2.6

逆变的引入说明

Variance Annotations - TS 4.7

in/out 官方文档

协变与逆变 - Wikipedia

理论全景(选读)

TypeScript FAQ - 方法 vs 函数属性

官方解释双向协变的历史


下一步

明天(Day 21)是第 3 周 BOSS 战:10 道 medium 精选冲刺(含本周终极挑战 Permutation)+ 周总结博客。今天攒的理论(变型)也将在题目复盘中派上用场。


协变逆变学完的标志:看到函数赋值,你的眼睛会自动扫描"参数谁宽、返回谁窄"。

每天花 2 小时,28 天通关 TypeScript 深入。第 3 周第 6 天,理论深水区通关!


评论