在TypeScript的项目里,你也许遇到过这样的场景:定义了一个接收Animal类型参数的函数,然后试图把它赋值给一个要求参数为Dog的函数类型变量,编译器却直接报错。直觉上Dog是Animal的子类型,这个赋值看起来完全合理,为什么不行?答案藏在类型系统的变型规则里,具体来说就是函数参数位置的逆变特性。理解这一点,是掌握TypeScript类型兼容性的关键一步。

协变与逆变:类型变型的两个方向
要理解逆变,必须先建立一个框架:当复合类型(比如函数、数组、泛型接口)内部的类型参数发生变化时,整个复合类型的兼容关系会朝什么方向演变。如果子类型关系保持同方向传递,就叫协变;如果方向反转,就叫逆变;如果既不协变也不逆变,就是不变(invariant)。
举个最直观的协变例子。假设有class Dog extends Animal,那么Dog[]就是Animal[]的子类型,数组的元素类型保持同方向兼容,这是协变。函数返回值位置同样是协变的:返回Dog的函数可以安全地赋值给返回Animal的函数类型,因为调用方拿到Dog后当作Animal使用完全没问题。
而函数参数位置恰恰相反。定义type Handler = (a: Animal) => void和type DogHandler = (d: Dog) => void,DogHandler可以赋值给Handler,但反过来不行。子类型关系在参数位置发生了反转,这就是逆变。这个方向反转不是设计者随意的选择,而是由调用安全性推导出来的必然结论。
为什么函数参数必须是逆变而不是协变
我们可以从调用安全的角度亲手推导一遍。假设TypeScript允许参数协变,也就是说允许把(a: Animal) => void赋值给DogHandler类型的变量会发生什么?看下面的代码:
class Animal { name: string = "animal"; }
class Dog extends Animal { bark(): void { console.log("woof"); } }
// 假设这行编译通过
const dogHandler: (d: Dog) => void = (a: Animal) => {
// 函数体按契约只知道参数是 Animal
// 一旦这里调用了只在 Cat 上存在的方法就会崩溃
console.log(a.name);
};
// 调用方按 DogHandler 的契约传入 Dog,这没问题
dogHandler(new Dog());
上面这个例子中传入Dog暂时安全,但换一个场景就危险了。假设Animal还有一个子类Fish,而DogHandler的调用方完全可能传入Fish对象——因为它坚信参数类型是Dog。而实际的函数实现却假设参数是Animal的任意子类,一旦实现内部依赖了更宽泛的假设,类型保证就被击穿了。
反过来,逆变是安全的。一个承诺处理任意Animal的函数,当然有能力处理Dog,因为Dog具备Animal的全部属性和方法。用一句更通俗的话总结:函数参数要求“给多少都能接住”,参数类型越宽泛(越靠父类),能力越强,能赋值的目标类型就越具体。这正是里氏替换原则在函数类型上的体现:接收者可以比声明类型的要求更宽松,但不能更严格。
strictFunctionTypes开关前后行为差异
值得注意的是,TypeScript在很长一段历史时期内对函数参数采用的是双变检查,也就是协变和逆变都放行。这是从JavaScript代码迁移的务实妥协,但带来了类型漏洞。直到2.6版本引入strictFunctionTypes编译选项,参数逆变才被严格检查。
启用strict模式时,strictFunctionTypes会自动开启,此时参数是严格的逆变检查。关闭它之后,编译器退回到双变模式,下面的赋值将不再报错:
// tsconfig 中 strictFunctionTypes: false 时 let wide: (a: Animal) => void; let narrow: (d: Dog) => void; wide = narrow; // 逆变方向,任何模式下都安全,通过 narrow = wide; // 协变方向,strictFunctionTypes 开启时报错,关闭时通过
还有一个细节需要留意:strictFunctionTypes只作用于函数类型的比较位置,通过方法声明语法定义的函数(在接口或类中用method(): void形式声明的成员)仍然保持双变。这也是为什么许多老牌库(比如早期的事件监听接口)倾向用方法简写而不是属性签名来定义回调,目的是减少使用者的类型报错。你可以理解为方法语法的兼容性更好,而属性语法method: (arg: T) => void则享有更严格的逆变检查。
参数数量、可选参数与rest参数的兼容规则
逆变只是函数兼容性的一环,参数个数也参与判断。基本原则是:源函数的参数个数不能多于目标类型的参数个数。也就是说,参数少的函数可以赋值给参数多的函数类型,反过来不行。这在回调场景中非常常见:
// 数组的 forEach 目标类型是 (value: string, index: number, array: string[]) => void // 但传入只用一个参数的回调完全合法 ["a", "b"].forEach((v: string) => console.log(v)); // 反过来则报错:需要两个参数的函数无法赋给只提供一个参数的类型 let one: (v: string) => void; let two: (v: string, i: number) => void; two = one; // 合法,one 忽略第二个参数 // one = two; // 错误,调用 one 时第二个参数无处获得
可选参数和rest参数的规则略有不同。当目标类型包含可选参数或rest参数时,源函数可以多出必选参数;而当源函数使用rest参数时,它相当于声明“任意个数”,规则会按照数组类型的元素兼容性来比较。可选参数在严格模式下与undefined的联合类型等价,因此在判断是否能赋值给必选参数时,需要考虑undefined是否可接受,这也是开启strictNullChecks后常见的一类赋值报错来源。
把这几条规则合起来看:参数类型看逆变方向,参数个数只减不增,可选与rest参数有专门豁免。掌握了这套规则之后,再遇到类似“类型不兼容”的报错,先检查赋值发生在函数类型之间还是普通对象之间,再看参数位置的方向是否符合逆变要求,大部分疑难杂症都能迅速定位。更进一步,理解逆变之后,你在编写Validator<T>这类接收处理函数的泛型工具时,就能准确预判Validator<Animal>与Validator<Dog>之间能否互相赋值,写出真正类型安全的抽象。
TypeScript逆变类型兼容性函数参数修改时间:2026-08-31 03:32:38