1. 类型系统基础:从鸭子类型谈起
刚接触TypeScript时,很多人会被它的类型系统弄得晕头转向。我最初看到"协变"和"逆变"这两个词时也是一头雾水,直到有一天在调试一个复杂的泛型接口时突然开窍。今天我们就来彻底搞懂这个让无数开发者头疼的概念。
TypeScript采用的是结构化类型系统(structural typing),也就是俗称的"鸭子类型"——如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。这种灵活性带来了便利,但也埋下了类型兼容性的复杂问题。比如下面这个例子:
typescript复制interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
let animal: Animal = { name: "Generic Animal" };
let dog: Dog = { name: "Buddy", breed: "Golden Retriever" };
animal = dog; // ✅ 兼容
dog = animal; // ❌ 不兼容
这个例子展示了最基本的类型兼容规则:子类型可以赋值给父类型(协变),但反过来不行。但当我们把场景换成函数类型时,情况就变得微妙起来了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 函数类型的兼容性迷宫
函数类型的兼容性比普通对象类型复杂得多,因为它需要考虑参数类型和返回值类型两个维度。来看一个实际开发中常见的例子:
typescript复制type Handler = (event: Event) => void;
const clickHandler: Handler = (mouseEvent: MouseEvent) => {
console.log(`Clicked at (${mouseEvent.clientX}, ${mouseEvent.clientY})`);
};
这里MouseEvent是Event的子类型,但为什么允许用子类型参数代替父类型?这看起来与对象赋值的规则相反。这就是函数参数逆变性的体现。
2.1 现实中的类型安全困境
假设我们有一个事件处理系统:
typescript复制class EventBus {
private handlers: Handler[] = [];
addHandler(handler: Handler) {
this.handlers.push(handler);
}
trigger(event: Event) {
this.handlers.forEach(h => h(event));
}
}
const bus = new EventBus();
bus.addHandler((e: MouseEvent) => console.log(e.clientX)); // 危险!
如果TypeScript允许这样的代码,运行时调用trigger时传入一个普通的Event对象(没有clientX属性),就会导致运行时错误。这就是为什么函数参数必须是逆变的——为了保证类型安全。
3. 协变与逆变的数学本质
从类型理论的角度看,协变和逆变描述的是类型构造器(如数组、函数等)与它们参数类型之间的关系。用集合论的语言来说:
- 协变:如果A ⊆
