协变与逆变
TypeScript 中协变、逆变、双变与不变的型变关系,涵盖函数子类型理论、Liskov 替换原则、严格函数类型检查、数组协变陷阱与 React props 逆变等核心议题
阅读提示:正文以代码和白话为主,不出现类型论公式。进阶文档中若出现
Γ ⊢ e : τ这类记号,第一遍可完全跳过(完整规则见001-HowToReadThisCourse)。
协变与逆变
前置知识
- 类型体操实用模式:建议先完成前一篇的学习
学习目标
- 掌握「1. 学习导论」的核心机制、典型用法与常见陷阱
- 掌握「2. 历史动机与技术演进」的核心机制、典型用法与常见陷阱
- 掌握「3. 形式化定义」的核心机制、典型用法与常见陷阱
- 掌握「4. 理论推导」的核心机制、典型用法与常见陷阱
- 掌握「5. TypeScript 中的协变」的核心机制、典型用法与常见陷阱
本文从类型论形式语义出发,系统阐述 TypeScript 类型系统中四种型变关系(协变、逆变、不变、双变)的数学定义、推导规则、工程实现与典型陷阱。所有数学公式使用 KaTeX 语法,所有代码示例使用 TypeScript 标注。
1. 学习导论
1.1 为什么必须理解型变
在 TypeScript 工程实践中,型变(variance)描述的是:当存在子类型关系 时,由它们构造出的复合类型 与 之间的子类型关系方向。这一概念看似抽象,却决定了以下三类日常代码能否通过类型检查:
- 回调函数赋值:将
(e: MouseEvent<HTMLButtonElement>) => void赋值给(e: MouseEvent<Element>) => void是否安全? - 泛型容器赋值:将
Array<Dog>赋值给Array<Animal>是否安全? - 依赖注入容器:将
Container<Service>注入到期望Container<BaseService>的位置是否安全?
理解型变不仅能让你写出正确的类型签名,更能让你在阅读 TypeScript 编译器错误信息时迅速定位问题根源。
1.2 Bloom 认知层次对照
| Bloom 层次 | 对应能力 | 本文对应章节 |
|---|---|---|
| remember | 记住四种型变定义 | 第 3 节 |
| understand | 理解推导规则 | 第 4 节 |
| apply | 配置 strictFunctionTypes、使用 in/out 修饰符 | 第 7、8 节 |
| analyze | 分析数组协变与 React props 逆变 | 第 9、10 节 |
| evaluate | 评估双变默认行为的工程权衡 | 第 7、12 节 |
| create | 设计类型安全的事件系统 | 第 13、14 节 |
1.3 阅读建议
- 入门读者:先读第 2、3、5、6 节,建立直觉。
- 工程实践者:跳到第 7、9、12、13 节,直接解决生产问题。
- 类型论研究者:精读第 3、4 节,对照 Pierce 的《Types and Programming Languages》第 15 章。
2. 历史动机与技术演进
2.1 类型论起源(1970s–1980s)
型变概念最早可追溯至 John C. Reynolds 1974 年关于参数化多态的论文,以及 Rod Burstall 与 John Lampson 1973 年在 Pebble 语言中对子类型化的早期探索。Luca Cardelli 与 Peter Wegner 在 1985 年发表于 ACM Computing Surveys 的经典论文《On understanding types, data abstraction, and polymorphism》首次系统化地将协变与逆变引入面向对象类型系统的教学语境,提出了至今仍被引用的分类框架:
子类型化的本质是多态的一种形式,它允许在不显式声明的情况下用一种类型的值替代另一种类型的值。
Cardelli-Wegner 框架将多态分为四类:
其中**包含多态(inclusion polymorphism)**即子类型化,正是协变与逆变讨论的核心舞台。
2.2 Liskov 替换原则(1987)
Barbara Liskov 在 1987 年的 OOPSLA 主题演讲《Data abstraction and hierarchy》中提出了著名的替换原则,1987 年版本非正式表述为:
如果 是 的子类型,那么类型为 的对象可以被类型为 的对象替换,而不会破坏程序的期望性质。
1994 年 Liskov 与 Jeannette Wing 在 ACM Transactions on Programming Languages and Systems 发表的论文《A behavioral notion of subtyping》给出了形式化定义,要求子类型在行为上满足可替换性约束:前置条件不能加强,后置条件不能减弱。这一约束正是函数参数逆变的行为语义基础。
2.3 TypeScript 演进时间线
| 时间 | 版本 | 事件 |
|---|---|---|
| 2012-10 | TypeScript 0.8 | 微软首次发布 TypeScript,函数参数默认双变 |
| 2014-04 | TypeScript 1.0 | 正式发布,引入 strict 模式雏形 |
| 2016-09 | TypeScript 2.0 | 引入 strictNullChecks,但函数仍双变 |
| 2018-06 | TypeScript 2.6 | 引入 strictFunctionTypes,首次提供严格逆变检查 |
| 2018-07 | TypeScript 3.0 | 引入 unknown 类型,为安全协变提供基础 |
| 2020-11 | TypeScript 4.1 | 模板字面量类型,使型变约束可在字符串层面表达 |
| 2022-03 | TypeScript 4.7 | 引入 in out 修饰符显式标注不变位置 |
| 2023-03 | TypeScript 5.0 | 稳定 in/out/const 修饰符,支持型变推导的精细化 |
| 2024-11 | TypeScript 5.7 | 改进型变检查路径,减少误报 |
2.4 原作者背景
- Luca Cardelli(1956–):意大利计算机科学家,Microsoft Research 前助理董事,ACM Fellow。其主页与论文合集位于
https://lucacardelli.name。 - Barbara Liskov(1939–):MIT 教授,2008 年图灵奖得主,抽象数据类型与分布式系统先驱。
- Gabriel Bierman(TypeScript 语义奠基者):Microsoft Research Cambridge,2014 年 ECOOP 论文《Understanding TypeScript》为 TypeScript 提供了首个学术级语义模型。
- Daniel Rosenwasser:TypeScript 项目主管,主导
strictFunctionTypes与in/out修饰符的设计。
3. 形式化定义
3.1 子类型关系
设 表示在类型环境 下 是 的子类型。子类型关系 是一个预序(preorder),满足:
3.2 型变的四类定义
给定类型构造子 ,如果 ,研究 与 之间的关系:
| 型变 | 关系 | 数学描述 |
|---|---|---|
| 协变 (Covariant) | ||
| 逆变 (Contravariant) | ||
| 不变 (Invariant) | 仅当 | |
| 双变 (Bivariant) | 双向都成立 |
形式化地,型变是类型构造子 的一个**函子性(functoriality)**性质。 是协变函子当且仅当:
是逆变函子当且仅当:
其中 表示子类型范畴的对偶范畴。
3.3 TypeScript 中各位置的型变归类
| 位置 | 默认型变 | strictFunctionTypes 后 |
|---|---|---|
| 函数返回类型 | 协变 | 协变 |
| 函数参数(function type) | 双变 | 逆变 |
| 函数参数(method shorthand) | 双变 | 双变(豁免) |
| 数组元素 | 协变 | 协变 |
Promise<T> 内部值 | 协变 | 协变 |
| 只读属性 | 协变 | 协变 |
| 可读写属性 | 不变(理论) / 协变(实际) | 同上 |
Record<K, T> 的 T | 协变 | 协变 |
注意 TypeScript 的”理论型变”与”实际型变”并不完全一致,这是为了与 JavaScript 动态语义保持兼容的工程妥协,详见第 9 节。
4. 理论推导
4.1 函数子类型化规则
考虑函数类型 与 。函数子类型化规则(function subtyping rule)定义为:
这条规则同时体现了参数逆变(,方向反转)与返回协变(,方向保持)。它的语义依据是 Liskov 替换原则:若用 替代 ,调用方按 传入参数,由于 ,参数对 也是合法的;返回值按 计算,由于 ,返回值对期望 的调用方也是合法的。
4.2 多参数函数的推导
对 元函数 ,子类型化规则推广为:
注意可选参数与剩余参数的特殊处理:
- 可选参数位置仍为逆变,但子类型可以比超类型有更少的必选参数。
- 剩余参数
...args: A[]等价于无限多个逆变位置。
4.3 子类型推导的可判定性
TypeScript 的子类型关系是可判定的,但复杂度较高。Bierman 等人在 2014 年 ECOOP 论文中证明 TypeScript 的结构子类型化在不考虑递归类型时是 的;引入递归类型后变为 ,且在极端情况下接近 PSPACE-hard。
4.4 Liskov 行为子类型约束
Liskov-Wing 1994 的行为子类型化规则在 TypeScript 中的体现:
即子类型方法的前置条件不能比超类型更严格,后置条件不能比超类型更宽松。这与型变推导在数学上同构:参数逆变保证前置条件不加强,返回协变保证后置条件不减弱。
4.5 复合型变规则
当多个型变位置复合时,遵循复合规则:
类比负负得正:协变是 ,逆变是 ,复合时按符号相乘。例如:
Array<(x: T) => void>对 T 是逆变(数组协变 × 函数参数逆变 = 逆变)。(f: (x: T) => void) => void对 T 是协变(外层参数逆变 × 内层参数逆变 = 协变)。
5. TypeScript 中的协变
5.1 基础示例:数组协变
// 定义基类与子类
interface Animal {
name: string;
age: number;
}
interface Dog extends Animal {
breed: string;
bark(): void;
}
interface Cat extends Animal {
purr(): void;
}
// 数组是协变的:Dog[] 可以赋值给 Animal[]
const dogs: Dog[] = [
{ name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ } },
{ name: 'Max', age: 5, breed: 'Labrador', bark() { /* ... */ } },
];
// 协变赋值:合法,但有运行时隐患
const animals: Animal[] = dogs;
数组协变在 TypeScript 中是默认行为,但其安全性依赖于开发者不通过该数组写入非 Dog 元素。第 9 节将详述这一陷阱。
5.2 Promise 与异步值的协变
// Promise<T> 对 T 是协变的
async function fetchDog(): Promise<Dog> {
return { name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ } };
}
// 可以直接赋值给 Promise<Animal>
const animalPromise: Promise<Animal> = fetchDog();
// then 的回调返回值也是协变位置
const processed: Promise<Animal> = fetchDog().then(dog => {
// dog: Dog,返回 Dog 也被协变接受为 Animal
return dog;
});
5.3 只读容器与协变
只读容器天然支持协变,因为它们没有写入接口,不存在数组协变那样的写入陷阱:
interface ReadonlyArray<T> {
// 只读接口,无 push/pop/splice 等写入方法
readonly length: number;
[n: number]: T;
map<U>(f: (x: T, i: number) => U): ReadonlyArray<U>;
filter(pred: (x: T, i: number) => boolean): ReadonlyArray<T>;
}
// 只读数组的协变是安全的
const readonlyDogs: ReadonlyArray<Dog> = dogs;
const readonlyAnimals: ReadonlyArray<Animal> = readonlyDogs; // 完全安全
5.4 返回值协变
函数返回类型是协变位置,子类型函数可以返回更具体的类型:
interface AnimalShelter {
adopt(): Animal;
}
interface DogShelter extends AnimalShelter {
// 重写返回更具体的类型 Dog,合法(协变)
adopt(): Dog;
}
// 实现示例
class ConcreteDogShelter implements DogShelter {
adopt(): Dog {
return { name: 'Buddy', age: 2, breed: 'Beagle', bark() { /* ... */ } };
}
}
5.5 协变与 unknown 上界
unknown 是 TypeScript 中所有类型的超类型,因此任何类型都是 unknown 的子类型,对 unknown 协变的位置可以接收任何值:
// 协变到 unknown:所有容器都是 unknown 容器
const anything: Promise<unknown> = (async () => 42)();
const anyArray: unknown[] = [1, 'two', { three: 3 }];
// 但反方向不行
const num: Promise<number> = anything; // Error: unknown 不能赋值给 number
6. TypeScript 中的逆变
6.1 基础示例:函数参数逆变
// 定义回调类型
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
// 一个只处理 Dog 的回调
const handleDog: DogHandler = (dog) => {
console.log(`Dog ${dog.name} of breed ${dog.breed}`);
};
// 在 strictFunctionTypes 下,DogHandler 是 AnimalHandler 的子类型
// 因为:调用方按 Animal 传入,由于 Dog <: Animal,传入的一定是 Dog
const handleAnimal: AnimalHandler = handleDog; // 逆变,合法
注意:上面这段代码”看起来”违反直觉(Dog 处理器能赋给 Animal 处理器?),但语义上是安全的——任何调用 handleAnimal(someAnimal) 的位置,someAnimal 实际上是 Dog 实例时才能成立。这要求赋值发生的语境中,someAnimal 必然是 Dog。
6.2 严格场景下的逆变失败
// 反方向:Animal 处理器赋给 Dog 处理器会失败
const handleAnyAnimal: AnimalHandler = (animal) => {
console.log(`Animal ${animal.name}, age ${animal.age}`);
};
const handleSpecificDog: DogHandler = handleAnyAnimal; // Error
// Error: Type '(animal: Animal) => void' is not assignable to type '(dog: Dog) => void'.
// Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
为什么这个方向不安全?因为调用方按 DogHandler 期望,会调用 handleSpecificDog(someDog),处理函数内部如果只使用 animal.name 等基类属性是安全的;但赋值方可能在使用 dog.bark() 时崩掉——其实这里 handleAnyAnimal 没用 bark,理论上安全,但 TypeScript 不做精细的行为分析,直接按逆变规则禁止。
6.3 Promise.then 的逆变位置
// Promise<T>.then 的 onFulfilled 回调接收 T,是逆变位置
const dogPromise: Promise<Dog> = Promise.resolve({
name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ }
});
// 回调参数逆变为 Animal 仍然安全
dogPromise.then((animal: Animal) => {
console.log(animal.name); // 只用基类属性,安全
});
6.4 事件处理器逆变
interface EventMap {
click: MouseEvent;
keydown: KeyboardEvent;
custom: CustomEvent<{ payload: string }>;
}
type Listener<K extends keyof EventMap> = (event: EventMap[K]) => void;
// 注册监听器:逆变位置
function addListener<K extends keyof EventMap>(
type: K,
listener: Listener<K>
): void {
// ...
}
// 监听 click 事件
const clickListener: Listener<'click'> = (e) => {
console.log(e.clientX, e.clientY);
};
addListener('click', clickListener);
如果尝试传入一个处理更宽泛 Event 的监听器,会失败:
const anyEventListener: (e: Event) => void = (e) => console.log(e.type);
addListener('click', anyEventListener); // Error
7. 双变与 strictFunctionTypes
7.1 默认双变行为
TypeScript 在 strictFunctionTypes 未启用时,函数参数位置是双变的,即协变与逆变都允许。这是出于与 JavaScript 动态语义兼容的考虑:
// 双变模式下:以下两种赋值都合法
const f1: (x: Animal) => void = (x: Dog) => console.log(x.breed);
const f2: (x: Dog) => void = (x: Animal) => console.log(x.name);
双变允许这种”双向赋值”,但牺牲了类型安全性:第二种赋值(Animal 处理器赋给 Dog 处理器)在运行时可能崩溃,因为处理器内部可能调用 dog.bark(),但传入的可能是 Cat。
7.2 启用 strictFunctionTypes
在 tsconfig.json 中配置:
{
"compilerOptions": {
"strict": true,
"strictFunctionTypes": true
}
}
strict: true 已包含 strictFunctionTypes: true,所以现代 TypeScript 工程默认启用。
7.3 方法语法的豁免
TypeScript 对方法语法(method shorthand)和函数属性语法(function property)采取不同的型变策略:
interface WithMethod {
// 方法语法:始终双变
handle(x: Animal): void;
}
interface WithFunctionProperty {
// 函数属性语法:受 strictFunctionTypes 约束
handle: (x: Animal) => void;
}
// 双变 vs 逆变的差异
const dogHandler: (x: Dog) => void = (x) => console.log(x.breed);
const m: WithMethod = { handle: dogHandler }; // 合法(双变)
const f: WithFunctionProperty = { handle: dogHandler }; // Error(严格逆变)
设计原因:方法语法通常用于类与接口的面向对象场景,许多现有库依赖双变行为;函数属性语法更函数式,更接近纯函数子类型理论。
7.4 工程权衡
TypeScript 团队选择默认双变 + 可选严格逆变的设计理由:
- 兼容性:大量现有 JavaScript 库的 .d.ts 文件依赖双变,强制严格会引入海量类型错误。
- 渐进式类型化:JavaScript 迁移到 TypeScript 的项目需要宽松的过渡期。
- 方法 vs 函数语义:面向对象的方法重写与函数式组合有不同的子类型化期望。
- 错误信息友好性:双变模式下错误更少,对初学者更友好。
代价是类型安全性降低。生产级项目强烈建议启用 strictFunctionTypes,并在 .d.ts 编写时优先使用函数属性语法。
8. 型变与泛型修饰符
8.1 TypeScript 5.0+ 的 in/out 修饰符
TypeScript 5.0 引入 in/out 修饰符,允许开发者显式声明泛型参数的型变方向:
// 协变:T 只出现在输出位置
interface Producer<out T> {
produce(): T;
}
// 逆变:T 只出现在输入位置
interface Consumer<in T> {
consume(value: T): void;
}
// 不变:T 同时出现在输入与输出位置
interface Storage<in out T> {
get(): T;
set(value: T): void;
}
修饰符的核心价值:
- 文档化:让接口的型变意图显式。
- 编译期检查:TypeScript 会校验 T 是否真的只在声明的方向出现。
- 错误提示:违反型变时给出明确错误。
8.2 修饰符的编译期校验
// 错误:声明为协变但 T 出现在输入位置
interface BadProducer<out T> {
produce(): T;
reset(value: T): void; // Error: 'T' is specified as 'out' here...
}
这种校验避免了开发者无意中破坏型变约束。
8.3 修饰符与默认行为
未使用修饰符时,TypeScript 推断默认型变:
- 只读属性、返回值位置:协变
- 只写属性、参数位置:逆变(strictFunctionTypes 下)
- 同时出现:不变
显式修饰符会覆盖默认推断,并在跨文件使用时提供更稳定的接口契约。
8.4 与 Scala/C# 的对比
Scala 的声明站点型变:
// Scala
trait Producer[+T] { def produce(): T } // + 表示协变
trait Consumer[-T] { def consume(t: T): Unit } // - 表示逆变
trait Storage[T] // 无修饰符表示不变
C# 在接口层面使用 out/in 关键字:
// C#
interface IProducer<out T> { T Produce(); }
interface IConsumer<in T> { void Consume(T value); }
TypeScript 5.0+ 借鉴了 C# 的语法,但语义上更接近 Scala 的声明站点型变。
8.5 工程实践:何时使用修饰符
| 场景 | 建议 |
|---|---|
| 公开 API 的库 | 强烈建议使用,提高接口契约清晰度 |
| 内部工具类型 | 可选,根据复杂度决定 |
| 临时类型别名 | 不建议,会增加维护成本 |
| 函数泛型 | 不适用,修饰符只能用于接口/类型别名 |
9. 数组协变陷阱
9.1 经典反例
数组协变在 1960 年代 ALGOL 之后就已知是类型不安全的。Java 在 1995 年继承了这一设计,TypeScript 又从 Java 借鉴。考虑以下代码:
const dogs: Dog[] = [
{ name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ } },
];
const animals: Animal[] = dogs; // 协变赋值
interface Cat extends Animal {
purr(): void;
}
const cat: Cat = { name: 'Whiskers', age: 2, purr() { /* ... */ } };
// 通过 animals 数组写入 Cat!
animals.push(cat);
// 但 dogs[1] 现在是 Cat,调用 bark 会崩
dogs[1].bark(); // 运行时错误:cat 没有 bark 方法
TypeScript 编译器不会报错,但运行时会崩溃。这就是数组协变的根本矛盾:协变只保证读安全,不保证写安全。
9.2 修复方案
方案 1:使用只读数组类型
const dogs: Dog[] = [/* ... */];
// 使用 ReadonlyArray<T>,禁止写入操作
const animals: ReadonlyArray<Animal> = dogs;
// animals.push(cat); // 编译期错误:push 不存在于 ReadonlyArray
只读数组的协变是安全的,因为没有写入接口。
方案 2:使用元组类型
// 元组是不变的
const dogTuple: [Dog, Dog] = [
{ name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ } },
{ name: 'Max', age: 4, breed: 'Lab', bark() { /* ... */ } },
];
// const animalTuple: [Animal, Animal] = dogTuple; // Error
元组长度固定且每个位置独立,TypeScript 将其视为不变。
方案 3:显式复制
const animals: Animal[] = [...dogs]; // 浅拷贝
animals.push(cat); // 不影响 dogs
方案 4:使用 in out 修饰符自定义不变容器
interface InvariantArray<in out T> {
get(index: number): T;
set(index: number, value: T): void;
push(value: T): number;
// ...
}
// InvariantArray<Dog> 不能赋值给 InvariantArray<Animal>
9.3 TypeScript 编译器的妥协
TypeScript 历史上未对数组施加不变约束,原因包括:
- JavaScript 语义:JS 数组天生可写,强制不变会破坏大量现有代码。
- 渐进式类型化:迁移项目需要宽松的类型。
- 库兼容性:数以千计的 npm 库依赖数组协变。
工程实践中,建议在 API 设计层使用 ReadonlyArray<T> 或 readonly T[] 作为参数类型,避免数组协变陷阱。
9.4 ReadonlyArray 与 readonly 修饰符
function processAnimals(animals: readonly Animal[]): void {
// 只读遍历,安全
animals.forEach(a => console.log(a.name));
}
// 传入 Dog[] 也合法
processAnimals(dogs);
readonly T[] 是 ReadonlyArray<T> 的语法糖,二者等价。
10. React Props 逆变案例
10.1 问题场景
React 组件 props 中的回调函数是最常见的逆变陷阱现场:
import { MouseEvent } from 'react';
interface ButtonProps {
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
}
interface DivProps {
onClick: (e: MouseEvent<HTMLDivElement>) => void;
}
// 假设我们有一个通用 click 处理器
function handleClick(e: MouseEvent<Element>): void {
console.log('clicked', e.currentTarget);
}
// 直接使用会失败
function MyButton() {
return <button onClick={handleClick} />; // Error
// Error: Types of parameters 'e' and 'event' are incompatible.
// Type 'MouseEvent<HTMLButtonElement>' is missing properties from type 'MouseEvent<Element>'.
}
为什么失败?因为 MouseEvent<HTMLButtonElement> 是 MouseEvent<Element> 的子类型(HTMLButtonElement <: Element),按逆变规则,(e: MouseEvent<Element>) => void 不是 (e: MouseEvent<HTMLButtonElement>) => void 的子类型。
10.2 修复方案
方案 1:泛型分发
function handleClick<E extends Element>(e: MouseEvent<E>): void {
console.log('clicked', e.currentTarget);
}
function MyButton() {
return <button onClick={handleClick<HTMLButtonElement>} />;
}
方案 2:上下文类型推断
React 内部对 onClick 等事件处理器使用上下文类型推断,会自动推导出 MouseEvent<HTMLButtonElement>:
function MyButton() {
// 参数 e 自动推断为 MouseEvent<HTMLButtonElement>
return <button onClick={(e) => {
console.log(e.currentTarget); // HTMLButtonElement
}} />;
}
方案 3:宽泛处理器 + 类型守卫
function handleClick(e: MouseEvent<Element>): void {
if (e.currentTarget instanceof HTMLButtonElement) {
// 收窄到 HTMLButtonElement
console.log('button clicked', e.currentTarget.name);
}
}
function MyButton() {
// 类型断言绕过检查
return <button onClick={handleClick as (e: MouseEvent<HTMLButtonElement>) => void} />;
}
第三种方案不推荐,仅在没有其他选择时使用。
10.3 React 内部的型变规避
React 18+ 对事件处理器类型做了重新设计,使用泛型分发与上下文类型让事件类型在分发时被锁定到具体元素:
// React 18 类型定义(简化版)
interface DOMAttributes<T> {
onClick?: React.MouseEventHandler<T>;
// ...
}
type MouseEventHandler<T> = (event: MouseEvent<T>) => void;
interface ButtonHTMLAttributes<T> extends HTMLAttributes<T>, DOMAttributes<T> {
// ...
}
// 使用时 T 被推导为 HTMLButtonElement
function button(props: ButtonHTMLAttributes<HTMLButtonElement>): JSX.Element;
这种设计让 onClick 的回调类型在组件级别就被锁定到 MouseEvent<HTMLButtonElement>,避免了不安全的向上分发。
10.4 高阶组件的逆变陷阱
interface WithDataProps<T> {
data: T;
render: (item: T) => React.ReactNode;
}
function withData<T>(Component: React.ComponentType<WithDataProps<T>>) {
return function Wrapped(props: WithDataProps<T>) {
return <Component {...props} />;
};
}
// 使用
interface User { id: number; name: string; }
const UserList = withData<User>(({ data, render }) => (
<ul>{render(data)}</ul>
));
// 这里 render 的参数类型被锁定到 User
<UserList data={{ id: 1, name: 'Alice' }} render={u => <li>{u.name}</li>} />;
如果 withData 的泛型未正确约束,可能出现逆变失败:传入的 render 期望 User 但实际收到 Animal。
11. 对比分析
11.1 与 Scala 的对比
Scala 在语言层面强制声明站点型变:
// Scala
trait List[+T] // 协变
trait Function1[-T, +R] extends Function[T, R] // 参数逆变、返回协变
// 编译器会校验:
// trait Bad[+T] { def add(t: T): Unit } // 编译错误
TypeScript 5.0+ 的 in/out 修饰符借鉴了 Scala,但仍是可选的,默认行为是宽松的。
11.2 与 Java 的对比
Java 在数组层面协变(不安全设计),在泛型层面不变(类型擦除要求):
// Java 数组协变
Animal[] animals = new Dog[10];
animals[0] = new Cat(); // ArrayStoreException at runtime
// Java 泛型不变
List<Dog> dogs = new ArrayList<>();
// List<Animal> animals = dogs; // 编译错误
List<? extends Animal> covariant = dogs; // 通配符协变
List<? super Dog> contravariant = new ArrayList<Animal>(); // 通配符逆变
TypeScript 没有通配符,但通过 in/out 修饰符与只读类型提供了类似的灵活性。
11.3 与 C# 的对比
C# 在 CLR 层面强制数组协变(运行时检查),在委托层面支持显式型变:
// C# 数组协变(引用类型)
object[] arr = new string[10];
arr[0] = 5; // ArrayTypeMismatchException at runtime
// C# 委托型变
delegate T Func<out T>();
delegate void Action<in T>(T obj);
C# 与 TypeScript 在型变修饰符语法上最接近(都用 in/out),但 C# 的约束更严格。
11.4 与 Rust 的对比
Rust 没有传统的子类型化,但通过生命周期子类型化实现型变:
// Rust 生命周期协变
fn longest<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// 'b: 'a 表示 'b 比 'a 长,&'b str 可以赋值给 &'a str
Rust 的型变系统更关注生命周期安全性,而非对象子类型化。
11.5 与 Flow 的对比
Facebook 的 Flow 类型检查器在函数参数上采用严格逆变,不像 TypeScript 那样默认双变。这是 Flow 与 TypeScript 早期最显著的设计差异之一。
11.6 综合对比表
| 特性 | TypeScript | Scala | Java | C# | Rust |
|---|---|---|---|---|---|
| 数组协变 | 默认 | 不可 | 默认 | 引用类型默认 | 不存在 |
| 函数参数默认型变 | 双变 | 逆变 | 不变(无 lambdas) | 逆变 | N/A |
| 显式型变修饰符 | in/out(5.0+) | +/- | 无(用通配符) | in/out | 无(推导) |
| 声明站点 vs 使用站点 | 声明 | 声明 | 使用 | 声明 | 推导 |
| 严格模式 | strictFunctionTypes | 始终严格 | 始终严格 | 始终严格 | 始终严格 |
12. 常见陷阱与修复
12.1 陷阱 1:误以为函数参数协变
// 错误理解:以为 (x: Animal) => void 是 (x: Dog) => void 的子类型
const f1: (x: Dog) => void = (x: Animal) => console.log(x.name); // strictFunctionTypes 下报错
// 正确理解:函数参数逆变,Dog 处理器才能赋给 Animal 处理器
const f2: (x: Animal) => void = (x: Dog) => console.log(x.breed);
修复:始终记住”参数逆、返回协”的口诀。
12.2 陷阱 2:方法语法 vs 函数属性语法
interface A {
f(x: Animal): void; // 方法语法,双变
}
interface B {
f: (x: Animal) => void; // 函数属性语法,逆变
}
const dogHandler: (x: Dog) => void = (x) => { /* ... */ };
const a: A = { f: dogHandler }; // 合法
const b: B = { f: dogHandler }; // Error
修复:根据需要的型变严格程度选择语法。生产代码推荐函数属性语法。
12.3 陷阱 3:泛型推断导致的型变丢失
function makeHandler<T>(handler: (x: T) => void): (x: T) => void {
return handler;
}
const animalHandler = makeHandler<Animal>((x) => console.log(x.name));
const dogHandler: (x: Dog) => void = animalHandler; // Error
// 修复:使用泛型分发
function makeHandlerSafe<T>(): <U extends T>(x: U) => void {
return (x) => console.log((x as T).name);
}
12.4 陷阱 4:事件监听器的过度宽泛
// 错误:监听器参数宽于实际事件类型
window.addEventListener('click', (e: Event) => {
// e 被推断为 Event,而非 MouseEvent
console.log(e.clientX); // Error: Property 'clientX' does not exist on type 'Event'
});
// 修复:让 TypeScript 自动推断
window.addEventListener('click', (e) => {
console.log(e.clientX); // MouseEvent,合法
});
12.5 陷阱 5:使用 any 绕过型变
// 错误:用 any 绕过,失去类型安全
const unsafeHandler: (x: Dog) => void = ((x: Animal) => console.log(x.name)) as any;
// 修复:使用 unknown + 类型守卫
const safeHandler: (x: Dog) => void = (x) => {
const animal = x as unknown as Animal;
console.log(animal.name);
};
12.6 陷阱 6:可变数组与协变
// 错误:通过协变数组写入不安全
const dogs: Dog[] = [/* ... */];
const animals: Animal[] = dogs;
animals.push(someCat); // 运行时崩溃风险
// 修复:使用 ReadonlyArray 或显式复制
const readonlyAnimals: ReadonlyArray<Animal> = dogs;
// readonlyAnimals.push(someCat); // 编译错误
12.7 陷阱 7:Promise 链中的型变丢失
// 错误:then 回调返回值被推断为 Animal,丢失 Dog 信息
const dogPromise: Promise<Dog> = fetchDog();
const processed = dogPromise.then((dog) => {
return dog as Animal; // 返回类型显式收窄
});
// processed: Promise<Animal>,但可能希望保留 Promise<Dog>
// 修复:保留具体类型
const preserved = dogPromise.then((dog) => {
// 不显式收窄,TypeScript 自动推导返回 Dog
return dog;
});
// preserved: Promise<Dog>
12.8 陷阱 8:使用 in/out 修饰符过度约束
// 过度约束:明明只是协变位置却用 in out
interface Container<in out T> {
get(): T;
}
// 修复:根据实际使用场景选择修饰符
interface ReadContainer<out T> {
get(): T;
}
13. 工程实践
13.1 tsconfig.json 配置
{
"compilerOptions": {
"strict": true, // 启用所有严格检查,包括 strictFunctionTypes
"strictFunctionTypes": true, // 显式声明(已被 strict 包含)
"noImplicitAny": true, // 禁止隐式 any
"strictNullChecks": true, // 严格 null 检查
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true // 跳过 .d.ts 检查,避免第三方类型问题
}
}
13.2 ESLint 配置
{
"rules": {
"@typescript-eslint/strict-boolean-expressions": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/no-unsafe-argument": "error"
}
}
13.3 类型安全的回调注册器
/**
* 类型安全的事件注册器
* 利用逆变与映射类型确保事件名与回调类型一一对应
*/
interface EventMap {
userCreated: { id: string; name: string };
userDeleted: { id: string };
postPublished: { postId: string; authorId: string };
}
class TypedEventEmitter<Events extends Record<string, any>> {
private listeners: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {};
/**
* 注册事件监听器
* @param event 事件名
* @param listener 监听器函数(参数类型逆变为 Events[K])
*/
on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): this {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
return this;
}
/**
* 触发事件
* @param event 事件名
* @param payload 事件载荷(必须精确匹配 Events[K] 类型)
*/
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
const callbacks = this.listeners[event];
if (callbacks) {
callbacks.forEach(cb => cb(payload));
}
}
/**
* 注销事件监听器
*/
off<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): this {
const callbacks = this.listeners[event];
if (callbacks) {
const idx = callbacks.indexOf(listener);
if (idx !== -1) {
callbacks.splice(idx, 1);
}
}
return this;
}
}
// 使用示例
const emitter = new TypedEventEmitter<EventMap>();
// 正确:参数类型与事件载荷匹配
emitter.on('userCreated', (payload) => {
console.log(payload.id, payload.name); // payload 自动推断为 { id: string; name: string }
});
// 错误:参数类型不匹配
// emitter.on('userCreated', (payload: { id: string }) => { /* ... */ }); // Error
emitter.emit('userCreated', { id: '123', name: 'Alice' });
// emitter.emit('userCreated', { id: '123' }); // Error: 缺少 name
13.4 容器抽象与型变
/**
* 协变容器:生产者
* 仅暴露读取接口
*/
interface Producer<out T> {
get(): T;
map<U>(f: (x: T) => U): Producer<U>;
}
/**
* 逆变容器:消费者
* 仅暴露写入接口
*/
interface Consumer<in T> {
accept(value: T): void;
}
/**
* 不变容器:仓库
* 同时暴露读写接口
*/
interface Repository<in out T> {
findById(id: string): T;
save(value: T): void;
delete(value: T): void;
}
// 实现
class ListProducer<out T> implements Producer<T> {
constructor(private items: readonly T[]) {}
get(): T {
return this.items[0];
}
map<U>(f: (x: T) => U): Producer<U> {
return new ListProducer(this.items.map(f));
}
}
// 使用
const dogProducer: Producer<Dog> = new ListProducer([
{ name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ } },
]);
const animalProducer: Producer<Animal> = dogProducer; // 协变赋值,合法
13.5 依赖注入容器的型变
/**
* 简化的依赖注入容器
* 利用型变确保服务注册与解析的类型安全
*/
interface ServiceRegistry {
[key: string]: unknown;
}
class DIContainer<Services extends ServiceRegistry> {
private services = new Map<keyof Services, unknown>();
/**
* 注册服务
*/
register<K extends keyof Services>(
key: K,
factory: () => Services[K]
): this {
this.services.set(key, factory());
return this;
}
/**
* 解析服务
*/
resolve<K extends keyof Services>(key: K): Services[K] {
const service = this.services.get(key);
if (!service) {
throw new Error(`Service ${String(key)} not registered`);
}
return service as Services[K];
}
}
// 使用
interface AppServices {
db: Database;
logger: Logger;
cache: CacheService;
}
const container = new DIContainer<AppServices>();
container.register('db', () => new PostgreSQLDatabase());
container.register('logger', () => new ConsoleLogger());
const db = container.resolve('db'); // Database
// const invalid = container.resolve('invalid'); // Error: 不在 AppServices 中
13.6 函数组合的型变
/**
* 函数组合:compose(f, g)(x) = f(g(x))
* 需要满足 g: A => B, f: B => C
*/
function compose<A, B, C>(
f: (b: B) => C,
g: (a: A) => B
): (a: A) => C {
return (a: A) => f(g(a));
}
// 利用协变,可以将返回更具体类型的函数组合
const toDog = (id: string): Dog => ({
name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ }
});
const toAnimal = (dog: Dog): Animal => {
// 返回类型协变:返回 Dog 也合法
return dog;
};
const pipeline = compose(toAnimal, toDog);
const result: Animal = pipeline('123');
13.7 React 高阶组件的型变
import React from 'react';
/**
* 高阶组件:注入额外 props
* 利用型变确保 Props 类型正确传递
*/
function withLoading<P extends object>(
Component: React.ComponentType<P>
): React.ComponentType<Omit<P, 'loading'>> {
return function Wrapped(props: Omit<P, 'loading'>) {
const [loading, setLoading] = React.useState(false);
return <Component {...(props as P)} loading={loading} />;
};
}
interface DataComponentProps {
data: string[];
loading: boolean;
}
const DataComponent: React.FC<DataComponentProps> = ({ data, loading }) => {
if (loading) return <div>Loading...</div>;
return <ul>{data.map((d, i) => <li key={i}>{d}</li>)}</ul>;
};
const WrappedComponent = withLoading(DataComponent);
// 使用时不需要传 loading
// <WrappedComponent data={['a', 'b']} />
14. 案例研究
14.1 案例一:RxJS 观察者模式
RxJS 的 Observer 接口是逆变的典型应用:
interface Observer<T> {
next: (value: T) => void;
error: (err: any) => void;
complete: () => void;
}
interface Observable<T> {
subscribe(observer: Observer<T>): Subscription;
}
// 利用协变,Observable<Dog> 可以赋值给 Observable<Animal>
const dogObservable: Observable<Dog> = {
subscribe(observer) {
observer.next({ name: 'Rex', age: 3, breed: 'Husky', bark() { /* ... */ } });
return { unsubscribe() {} };
}
};
const animalObservable: Observable<Animal> = dogObservable; // 协变
// 观察者逆变
const animalObserver: Observer<Animal> = {
next: (animal) => console.log(animal.name),
error: (e) => console.error(e),
complete: () => console.log('done'),
};
dogObservable.subscribe(animalObserver); // 逆变,合法
14.2 案例二:Express 中间件链
Express 中间件的 Request/Response 类型扩展是声明合并与型变的综合应用:
import express, { Request, Response, NextFunction } from 'express';
// 通过声明合并扩展 Request
declare module 'express-serve-static-core' {
interface Request {
user?: {
id: string;
role: 'admin' | 'user';
};
}
}
// 中间件类型:(req, res, next) => void
type Middleware = (req: Request, res: Response, next: NextFunction) => void;
// 鉴权中间件
const authMiddleware: Middleware = (req, _res, next) => {
// 通过某种方式设置 req.user
(req as any).user = { id: '123', role: 'admin' };
next();
};
// 受保护的路由处理器
type ProtectedHandler = (req: Request & { user: NonNullable<Request['user']> }, res: Response) => void;
const protectedHandler: ProtectedHandler = (req, res) => {
// 在 strictFunctionTypes 下:ProtectedHandler 不是 Middleware 的子类型
// 因为 req 参数更具体,违反逆变
res.json({ userId: req.user.id });
};
// 修复:使用类型断言
app.get('/protected', authMiddleware, protectedHandler as unknown as Middleware);
14.3 案例三:Redux reducer 的型变
Redux 的 reducer 模式利用了动作联合类型与状态协变:
type Action =
| { type: 'INCREMENT'; payload: number }
| { type: 'DECREMENT'; payload: number }
| { type: 'RESET' };
interface State {
count: number;
}
type Reducer<S = State, A extends Action = Action> = (
state: S,
action: A
) => S;
const counterReducer: Reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + action.payload };
case 'DECREMENT':
return { count: state.count - action.payload };
case 'RESET':
return { count: 0 };
default:
return state;
}
};
// 利用协变:返回更具体的子状态类型
interface StrictState extends State {
readonly lastUpdated: number;
}
const strictCounterReducer: Reducer<StrictState> = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + action.payload, lastUpdated: Date.now() };
// ...
default:
return state;
}
};
// 协变:Reducer<StrictState> 是 Reducer<State> 的子类型
const store: Reducer<State> = strictCounterReducer;
14.4 案例四:tRPC 的端到端类型安全
tRPC 利用型变实现前后端的端到端类型推导:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
// 路由定义
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.output(z.object({ id: z.string(), name: z.string() }))
.query(({ input }) => {
return { id: input.id, name: 'Alice' };
}),
});
// 推导出的客户端调用类型
type AppRouter = typeof appRouter;
// 客户端调用 getUser 时,input 与 output 类型严格匹配
tRPC 的核心机制是利用 TypeScript 的条件类型与型变推导,将服务端路由的输入输出类型传递到客户端。型变确保了类型传递的安全性。
14.5 案例五:Zod 与运行时验证
Zod 模式在运行时验证后产生类型,与 TypeScript 静态类型形成协变关系:
import { z } from 'zod';
// 运行时验证模式
const userSchema = z.object({
id: z.string(),
name: z.string(),
age: z.number().int().positive(),
});
// 静态类型推导
type User = z.infer<typeof userSchema>;
// 等价于 { id: string; name: string; age: number }
// 运行时验证
const parse = <T extends z.ZodType<any>>(schema: T, input: unknown): z.infer<T> => {
return schema.parse(input);
};
// 运行时与静态类型协变
const user = parse(userSchema, { id: '1', name: 'Alice', age: 30 });
// user: User,运行时验证通过
14.6 案例六:Effect-TS 的 Channel 型变
Effect-TS 库的 Channel 抽象使用型变约束实现生产者-消费者模式:
import { Channel, Effect } from 'effect';
// Producer:协变于产出值类型
const producer = Channel.fromIterable([1, 2, 3]);
// producer: Channel<number, unknown, never>
// Consumer:逆变于输入值类型
const consumer = Channel.readWith(
(n: number) => Effect.sync(() => console.log(n)),
() => Effect.unit,
() => Effect.unit
);
// 组合:Channel 协变与逆变的综合应用
const pipeline = Channel.pipeTo(producer, consumer);
填空题知识点讲解
-
[remember] 在 TypeScript 中,函数参数在 strictFunctionTypes 启用后处于____位置,子类型关系会____。
-
[remember] 数组在 TypeScript 中默认是____的,这是历史性的类型不安全设计,源于对 Java 数组协变的兼容借鉴。
-
[understand] 设 ,若 是协变函子,则 ;若 是逆变函子,则 。
-
[understand] TypeScript 5.0 引入的
out T修饰符表示 T 是____型变位置,in T表示____型变位置,in out T表示____。 -
[remember] Liskov 替换原则要求子类型方法的____不能加强,____不能减弱。
15.3 代码修复题(code-fix)
-
[apply] 以下代码启用了 strictFunctionTypes,但赋值会失败。请修复类型签名以使其通过类型检查。
type Callback<T> = (payload: T) => void; declare const dogCallback: Callback<Dog>; const animalCallback: Callback<Animal> = dogCallback;修复方案:
// Callback 的参数 T 是逆变位置,Callback<Dog> 是 Callback<Animal> 的子类型 // 因此原本的赋值应该通过;如果失败,说明 Callback 被错误设计为协变 // 修复:确保 Callback 用 in 修饰符(TS 5.0+)或保持函数签名 type Callback<in T> = (payload: T) => void; declare const dogCallback: Callback<Dog>; const animalCallback: Callback<Animal> = dogCallback; // 合法(逆变) -
[apply] 以下数组协变代码会导致运行时错误,请在不改变行为的前提下修复类型签名。
const dogs: Dog[] = [/* ... */]; const animals: Animal[] = dogs; animals.push(someCat); // 运行时崩溃修复方案:
// 方案 1:使用只读数组,禁止写入 const dogs: Dog[] = [/* ... */]; const animals: readonly Animal[] = dogs; // animals.push(someCat); // 编译错误:push 不存在于 readonly array // 方案 2:显式复制,避免共享引用 const animals: Animal[] = [...dogs]; animals.push(someCat); // 不影响 dogs -
[apply] 以下 React 代码在 strictFunctionTypes 下报错,请修复。
function handleAnyElement(e: MouseEvent<Element>) { console.log(e.currentTarget); } const button = <button onClick={handleAnyElement} />;修复方案:
// 方案 1:泛型分发 function handleElement<E extends Element>(e: MouseEvent<E>) { console.log(e.currentTarget); } const button = <button onClick={handleElement<HTMLButtonElement>} />; // 方案 2:让 React 自动推断 const button = <button onClick={(e) => { console.log(e.currentTarget); // e 自动推断为 MouseEvent<HTMLButtonElement> }} />; -
[apply] 以下泛型容器类型在 TypeScript 5.0+ 下应如何添加型变修饰符?
interface Box<T> { value: T; } interface Source<T> { next(): T; } interface Sink<T> { write(value: T): void; } interface Buffer<T> { read(): T; write(value: T): void; }修复方案:
interface Box<in out T> { // 可读写属性,不变 value: T; } interface Source<out T> { // 仅产出,协变 next(): T; } interface Sink<in T> { // 仅消费,逆变 write(value: T): void; } interface Buffer<in out T> { // 同时读写,不变 read(): T; write(value: T): void; }
15.4 开放题(open-ended)
-
[evaluate] 在 React 中,假设有
interface Props { onClick: (e: MouseEvent) => void }。父组件渲染<Child onClick={handleChildClick} />,其中handleChildClick: (e: MouseEvent<HTMLButtonElement>) => void。请从型变角度论证为什么这种赋值在 strictFunctionTypes 下是危险的,并说明 React 18+ 内部为何使用泛型分发规避此问题。 -
[evaluate] TypeScript 默认对函数参数采用双变策略,而 Flow、Scala、C# 等语言严格采用逆变。请从语言设计角度分析 TypeScript 选择双变默认的三个合理性理由,以及三个潜在风险。
-
[create] 设计一个类型安全的发布-订阅系统,要求:
- 事件名与事件载荷通过映射类型关联
- 订阅器参数逆变为事件载荷
- 发布器协变于事件载荷
- 支持事件命名空间的嵌套(如
'user.created'、'user.deleted') - 提供完整 TypeScript 实现与使用示例
-
[analyze] 阅读以下代码,分析为什么
sinks数组的赋值会失败,并给出两种修复方案:interface Sink<T> { write(value: T): void; } declare const animalSink: Sink<Animal>; declare const dogSink: Sink<Dog>; const sinks: Sink<Dog>[] = [animalSink]; // Error -
[create] 实现一个
DeepReadonly<T>类型,使其递归地将 T 的所有属性变为只读。要求:- 处理函数类型(应跳过)
- 处理数组类型(应变为 ReadonlyArray)
- 处理 Map/Set(应保持类型构造子但内部值只读)
- 处理循环引用(用 WeakMap 防止栈溢出,类型层面用 ConditionalTypes 推迟)
- 给出至少 5 个测试用例
17.1 书籍
- 《Types and Programming Languages》——Benjamin C. Pierce,MIT Press 2002。第 15 章”Subtyping”系统化讲解子类型理论,是型变讨论的圣经。
- 《Advanced Topics in Types and Programming Languages》——Benjamin C. Pierce(编),MIT Press 2004。第 1 章对子类型化的高级话题有深入展开。
- 《Programming TypeScript》——Boris Cherny,O’Reilly 2019。第 4 章对 TypeScript 函数型变有实战级讲解。
- 《Effective TypeScript》——Dan Vanderkam,O’Reilly 2019。第 3 章包含大量型变相关条款。
- 《Object-Oriented Programming in OCaml》——Didier Rémy,2023 在线版。对 OCaml 对象系统的型变有详尽论述。
17.2 论文
- Cook, W. R. 2009. On Understanding Data Abstraction, Revisited. OOPSLA Onward! 2009. 重新审视抽象数据类型与对象抽象的本质区别。
- Bruce, K. B., Cardelli, L., Castagna, G., et al. 1995. On Binary Methods. Theory and Practice of Object Systems 1, 3, 221–242. 讨论二进制方法对型变的影响。
- Castagna, G. and Pierce, B. C. 1994. Decidable bounded quantification. POPL ‘94. 子类型化的可判定性问题。
17.3 开源项目
- type-challenges(https://github.com/type-challenges/type-challenges):TypeScript 类型体操挑战集,包含大量型变相关题目。
- ts-toolbelt(https://github.com/millsp/ts-toolbelt):高级类型工具库,源码中大量使用型变技巧。
- effect(https://github.com/Effect-TS/effect):函数式效应系统库,其 Channel 与 Stream 抽象深度使用型变。
- fp-ts(https://github.com/gcanti/fp-ts):TypeScript 函数式编程库,Functor/Contravariant 类型类是型变的直接体现。
- io-ts(https://github.com/gcanti/io-ts):运行时类型验证库,与静态类型形成协变关系。
17.5 视频课程
- MIT 6.826: Principles of Computer Systems——MIT 课程,Lecture 12 涵盖类型系统与子类型化。
- University of Washington CSE 505: Concepts of Programming Languages——Dan Grossman 课程,对子类型理论有深入讲解。
- Cornell CS 6110: Advanced Programming Languages——Nate Foster 课程,Lecture 9 涵盖型变与多态。
17.6 学术讲座
- Luca Cardelli: “Type Systems”(ACM SIGPLAN Lectures, 1996)——Cardelli 亲授的型变基础讲座。
- Barbara Liskov: “Programming the Turing Lecture”(2009 图灵奖讲座)——Liskov 回顾抽象数据类型与替换原则的演进。
附录 A:型变决策树
为了便于工程实践,以下提供型变决策树,帮助开发者快速判断应该使用哪种型变策略:
flowchart TD
T0["当前类型 T 是输入位置(参数)还是输出位置(返回值)?"]
T1["输入位置"]
T2["仅输入 → 使用 in T(逆变)"]
T3["同时输入输出 → 使用 in out T(不变)"]
T4["输出位置"]
T5["仅输出 → 使用 out T(协变)"]
T6["同时输入输出 → 使用 in out T(不变)"]
T7["既是输入也是输出"]
T8["使用 in out T(不变)"]
T0 --> T1
T3 --> T4
T6 --> T7
T7 --> T8
附录 B:TypeScript 版本与型变支持对照
| TypeScript 版本 | 关键型变特性 |
|---|---|
| < 2.6 | 函数参数始终双变,无严格检查 |
| 2.6 | 引入 strictFunctionTypes,函数属性语法严格逆变 |
| 2.8 | 引入条件类型,间接支持型变推导 |
| 3.0 | 引入 unknown 类型,为安全协变提供基础 |
| 4.7 | 引入 in out 修饰符(实验性) |
| 5.0 | 稳定 in/out/const 修饰符,正式支持声明站点型变 |
| 5.7 | 改进型变检查路径,减少误报 |
附录 C:型变术语中英对照
| 英文 | 中文 | 缩写 |
|---|---|---|
| Covariance | 协变 | COV |
| Contravariance | 逆变 | CONTRA |
| Invariance | 不变 | INV |
| Bivariance | 双变 | BIV |
| Subtyping | 子类型化 | - |
| Liskov Substitution Principle | Liskov 替换原则 | LSP |
| Variance Annotation | 型变注释 | VA |
| Declaration-site Variance | 声明站点型变 | DSV |
| Use-site Variance | 使用站点型变 | USV |
附录 D:常见错误信息索引
| 错误码 | 错误信息 | 触发场景 | 修复建议 |
|---|---|---|---|
| TS2322 | Type ‘X’ is not assignable to type ‘Y’. Types of parameters are incompatible. | 函数参数逆变失败 | 检查参数子类型关系方向 |
| TS2345 | Argument of type ‘X’ is not assignable to parameter of type ‘Y’. | 调用时参数型变不匹配 | 调整参数类型或使用泛型分发 |
| TS2416 | Property ‘X’ in type ‘Y’ is not assignable to the same property in base type ‘Z’. | 方法重写型变失败 | 检查返回类型协变与参数逆变 |
| TS2554 | Expected N arguments, but got M. | 参数数量型变失败 | 检查可选参数与剩余参数 |
| TS2741 | Property ‘X’ is missing in type ‘Y’ but required in type ‘Z’. | 结构子类型化失败 | 补全缺失的必选属性 |