一、先搞清楚:TS 是怎么判断"两个类型兼容"的
简单说,TypeScript 用的是结构化类型(structural typing),也叫"鸭子类型(duck typing)"。
什么意思?就是它不关心你这个东西"叫什么名字、是不是同一个类",它只关心"你长什么样、有哪些成员"。
一句话记忆:长得像鸭子、叫起来像鸭子,那它就是鸭子。
举个最直观的例子:
ts
interface Point {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
// 把 Point3D 赋值给 Point,没问题
let p: Point;
const p3: Point3D = { x: 1, y: 2, z: 3 };
p = p3; // ✅ Point3D 拥有 Point 要求的全部属性(x、y),所以"兼容"
反过来,如果类型要求 z,你给的对象没有 z,那就不兼容:
ts
let p3: Point3D;
const p: Point = { x: 1, y: 2 };
p3 = p; // ❌ p 缺了 z 属性,装不下,不兼容
这跟 C++、Java 那种"类名必须对得上"的**名义类型(nominal typing)**完全不同。TS 只认"结构"。记住这个前提,下面函数的事情才好理解。
二、函数的兼容性,跟普通对象不一样
普通对象的兼容规则我们刚才看到了:属性多的(拥有方)能赋值给属性要求少的(目标方)。
但函数是个"反向"的东西------它要被调用,调的时候得传参、收返回值。所以函数的兼容性不看属性,只看两件事:
- 返回类型(return type)
- 参数列表(参数个数 + 参数类型)
而且判断时有个固定的"方向",一定要记牢:
把 A 赋值给 B,A 是 source(源),B 是 target(目标)。所有的兼容规则,都是站在"B 能不能安全地接收 A"这个角度来判断的。
举个总览的例子,先看个最让人迷惑的场景:
ts
type F1 = (x: number) => string;
type F2 = (x: number) => string;
const f1: F1 = (x) => String(x);
const f2: F2 = f1; // ✅ 一模一样,当然能赋值
这没毛病。但下面几种"长得不完全一样"的情况,才是今天的重点。我们一条条拆。
三、返回类型:协变(covariance)------"能装下就行"
规则很好记:source 的返回类型,必须是 target 返回类型的子类型(更具体)。
用人话讲:目标说"我要一个动物",你返回一个"狗"没问题,因为狗就是动物;但你若返回一个"更泛的东西",目标接不住,就不行。这叫协变------方向和继承关系一致(子类兼容父类)。
ts
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
type GetAnimal = () => Animal;
type GetDog = () => Dog;
const makeAnimal: GetAnimal = () => ({ name: '动物' });
const makeDog: GetDog = () => ({ name: '狗', bark() {} });
// 目标要 Animal,源返回 Dog:Dog 是 Animal 的子类型 ✅
makeAnimal = makeDog; // ✅ 协变,允许
// 目标要 Dog,源返回 Animal:Animal 不一定是 Dog ❌
makeDog = makeAnimal; // ❌ 报错:类型 "Animal" 不能赋值给类型 "Dog"
一句话总结 :返回类型,源比目标更具体就 OK(协变),更泛就报错。这一点和普通对象属性的方向是一致的。
四、参数个数:少可以,多不行
这是最反直觉、也最常用的一条。规则是:
source 的参数,可以比 target 少;但不能比 target 多。
为什么"少可以"?因为函数被调用时,调用方会按 target 的类型来传参。比如 target 说"我传一个参数",source 只收一个参数------那多传的?根本不会发生,因为类型层面只保证传一个。所以 source 参数少,调用时绝不会"接不住",安全。
反过来,如果 source 要两个参数,但 target 的调用方只肯传一个,那第二个参数就会是 undefined,不安全,所以报错。
ts
// 目标要求两个参数
type TwoArgs = (a: number, b: number) => void;
// ✅ 用一个只收一个参数的函数赋值 ------ 安全,多出来的参数只是没人接
const f1: TwoArgs = (a: number) => {
console.log(a);
};
// 反过来:目标只要求一个参数,你给一个要两个参数的函数
type OneArg = (a: number) => void;
const f2: OneArg = (a: number, b: number) => { // ❌ 报错:源比目标参数多
console.log(a, b);
};
最经典的实战场景就是回调函数。 很多 API 的回调签名带一堆参数,但你只关心其中一两个:
ts
// 假设某个库的回调类型是 (err, data) => void
type Callback = (err: Error | null, data: string) => void;
function fetchSomething(cb: Callback) {
cb(null, '结果');
}
// 你只关心 err,不想写 data 参数 ------ 完全没问题!
fetchSomething((err) => {
if (err) console.error(err);
});
一句话总结 :参数个数,源 ≤ 目标就安全(少参数能兼容多参数),多出来的参数会报错。这也就是为什么回调里你爱写几个参数就写几个。
五、参数类型:逆变(contravariance)------"越小越能扛活"
这一条是重头戏,也是最容易看晕的。
先看结论(在开启 strictFunctionTypes 的严格模式下):
source 的参数类型,必须是 target 参数类型的父类型(或更宽的类型)。 方向跟继承关系"反着来",所以叫逆变(contravariance)。
直觉上怎么理解?一个"能处理任意动物"的函数,当然也能处理"狗"(因为狗是动物的一种)。所以:凡是 target 要传 Dog 的地方,你塞一个"只认 Animal"的函数进去,绝对安全------它处理动物,狗也是动物,没问题。
来看代码:
ts
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
type NeedAnimal = (a: Animal) => void; // 目标:要一个能处理 Animal 的函数
type NeedDog = (d: Dog) => void; // 目标:要一个能处理 Dog 的函数
// ✅ 把"能处理 Animal"的函数,放到"需要 Dog"的位置
// 调用时只会传 Dog,而 Dog 是 Animal,函数接得住
const handler: NeedDog = (a: Animal) => {
console.log(a.name); // ✅ 逆变允许:Animal 是 Dog 的父类型
};
// ❌ 反过来:把"只能处理 Dog"的函数,放到"需要 Animal"的位置
// 万一调用方传了只猫(也是 Animal 但不是 Dog),d.bark() 就炸了
const handler2: NeedAnimal = (d: Dog) => {
console.log(d.bark()); // ❌ 严格模式下报错:Dog 不是 Animal 的父类型
};
⚠️ 注意 :上面这套"逆变"规则,只有在开启了
strictFunctionTypes(或整个strict)时才生效 。没开严格模式时,TS 对函数参数采用"双变(bivariant)"检查------也就是父子类型两边都放行,更宽松但也更容易埋下运行时的雷。写新项目建议无脑开strict。
一个隐藏的大坑:方法(method)不受严格模式管
同样是"函数的位置",写在类/接口里的方法(method 语法) ,TS 永远用双变 检查,哪怕你开了 strictFunctionTypes 也不管。但如果你把它写成"函数属性(property 语法)",就受严格模式约束。
ts
// ① 方法语法 ------ 双变,即使 strictFunctionTypes 开着也放行(可能不安全)
interface AnimalShelter {
accept(animal: Animal): void;
}
interface DogShelter {
accept(dog: Dog): void;
}
const dogShelter: DogShelter = {
accept(dog) { console.log(dog.bark()); },
};
const shelter: AnimalShelter = dogShelter; // ⚠️ 编译通过(双变),但运行时可能出问题
// ② 函数属性语法 ------ 受 strictFunctionTypes 约束,走逆变
interface AnimalShelter2 {
accept: (animal: Animal) => void;
}
interface DogShelter2 {
accept: (dog: Dog) => void;
}
const dogShelter2: DogShelter2 = {
accept(dog) { console.log(dog.bark()); },
};
const shelter2: AnimalShelter2 = dogShelter2; // ❌ 严格模式下报错
这也就是为什么很多人升级到严格模式后,把"方法"改成"函数属性"会突然冒出一堆类型报错------本质是 TS 对这两种写法采取了不同的兼容策略。
一句话总结:参数类型,**源比目标更"宽"(父类型)**就 OK(逆变);方法永远双变、函数属性才逆变,这是 TS 兼容性里最容易踩的坑。
六、可选参数与剩余参数
上面说的是"必填参数"。还有两种特殊情况:
1. 可选参数(?)
可选参数相当于"这个位置可能有、也可能没有"。规则还是看方向:
- 如果 target 的参数是可选 的,source 给必填的,会报错------因为 target 可能不传,source 却一定要。
- 如果 target 的参数是必填 的,source 给可选 的,OK------source 反正也能收(不传就是
undefined)。
ts
type NeedRequired = (a: number) => void;
type NeedOptional = (a?: number) => void;
const fA: NeedRequired = (a?: number) => {}; // ❌ 源参数是可选的,目标要必填,报错
const fB: NeedOptional = (a: number) => {}; // ✅ 源必填、目标可选,放行
2. 剩余参数(...args)
剩余参数能接纳"任意多个"参数,所以它天然是"最宽松"的那一方,可以兼容各种参数个数的函数类型:
ts
type Log = (...args: any[]) => void;
const log: Log =
(msg: string) => console.log(msg); // ✅ 一个参数
// 也可以换成一个收两个参数的,照样兼容
const log2: Log =
(a: string, b: number) => console.log(a, b); // ✅ 两个参数
一句话总结:可选参数看"谁更可能被少传",剩余参数最宽松、能接任意个数。
七、总结:你只需要记住这三句口诀
函数类型兼容性听起来绕,其实就三句话:
| 维度 | 口诀 | 方向 |
|---|---|---|
| 返回类型 | 源比目标更具体 | 协变(跟继承同向) |
| 参数个数 | 源 ≤ 目标(少参数能吃多参数) | ------ |
| 参数类型 | 源比目标更宽泛(父类型) | 逆变(跟继承反向) |
还有两个容易忘的"例外":
- 方法(method 语法)永远双变 ,不受
strictFunctionTypes约束;函数属性才走逆变。 - 上面所有规则的前提,是你开了
strict/strictFunctionTypes。没开的话,参数类型一律双变,看着宽松,但运行时坑更多。
必须记住的三个点
- 返回类型协变 ------ 目标要动物、你返回狗,没问题;反过来不行。
- 参数少能兼容多 ------ 这就是为什么回调里你写
(err) => {}就能对付(err, data) => void。 - 参数类型逆变 + 方法双变 ------ 严格模式下源参数得更"宽";类/接口里的方法是个例外,永远双变。
掌握了这三句口诀,以后看到 类型 X 不能赋值给类型 Y 的函数报错,照着"返回、个数、类型"逐个对一遍,基本都能秒懂是哪条规则踩了。