真正理解TS协变与逆变
一、协变、逆变和不变
协变和逆变都是术语,协变指的是能够使用比原来类型的派生程度更大(更具体的)的类型,逆变指的是能够使用比原来类型的派生程度更小(不太具体的)的类型 。 还有一种是不变,也有人称之为固定类型,也就是使用最初定义指定的类型。固定类型既不是协变也不是逆变。 在Typescript中函数支持协变和逆变,在分配和使用类型时提供更大的灵活性,函数的参数具有逆变的特性,返回值则具有协变的特性。所谓逆变指的是父类型可以赋给子类型。使用ts声明函数类型时函数参数类型是子类型,实际可以赋值的函数参数是父类型。 而协变指的是返回值的子类型可以赋值给父类型。
在过往的开发中遇到这样一个问题,下面是一个通过type在ApiMap中映射相关api的场景,相信有的同学在开发中可能会这样写,这段代码看着非常正常且合理,但是下面但是这样我们的编译器就会出现ts的报错,可是当我们遇到 api(type === SourceType.Use_A ? 1 : 'str')
类型推断却出了问题
type Api_A = (arg: number) => number;
type Api_B = (arg: string) => string;
const api_a: Api_A = _ => _;
const api_b: Api_B = _ => _;
enum SourceType {
Use_A,
Use_B
}
const ApiMap = {
[SourceType.Use_A]: [api_a],
[SourceType.Use_B]: [api_b]
} as const;
const handleClick = (type: SourceType) => {
const [api] = ApiMap[type];
api(type === SourceType.Use_A ? 1 : 'str')
}
type Api_A = (arg: number) => number;
type Api_B = (arg: string) => string;
const api_a: Api_A = _ => _;
const api_b: Api_B = _ => _;
enum SourceType {
Use_A,
Use_B
}
const ApiMap = {
[SourceType.Use_A]: [api_a],
[SourceType.Use_B]: [api_b]
} as const;
const handleClick = (type: SourceType) => {
const [api] = ApiMap[type];
api(type === SourceType.Use_A ? 1 : 'str')
}
上面会出现(可以将上述代码粘贴在VS Code中)错误'string|number' is not assignable to 'never'
从这段错误提示中我们不难看出 api
的类型被推断成了 (arg: never) => number | string
。可是按理来说 api 的类型应该是 Api_A | Api_B
而且编译器也确实提示为该类型。事实上知道调用之前它一直是如此
那么为什么调用它的类型发生了变化了呢,本文将介绍TypeScript 类型系统中的协变与逆变,同时提供示例来帮助读者更好地理解这些概念,最终接到上述问题
二、结构化
首先介绍一下结构化,结构化类型是 TypeScript 中类型系统的一大特点,其中一个典型的例子就是接口(interface)。接口用于描述对象的形状,而不关心对象的具体实现或名称。只要对象的结构符合接口的定义,即可视为兼容。例如:
interface Name {
name: string;
}
interface Age {
age: number
}
// 从结构上看,Son是子类型
interface Son {
name: string;
age: number;
}
interface Name {
name: string;
}
interface Age {
age: number
}
// 从结构上看,Son是子类型
interface Son {
name: string;
age: number;
}
子类比父类更具体(属性更多),Son 是 Name 和 Age 的共有子类型,Name、Age没有关联
三、型变
型变是指类型之间的转换关系,在 TypeScript 中分为协变、逆变、双向协变和不变。通过理解这些概念,可以更好地管理类型间的兼容性和约束。
父子类型
让我们来写一个简单的父子类型:
Dog 继承于 Animal,拥有比 Animal 更多的方法。因此我们说 Animal 是父类型,Dog 是它的子类型。需要注意的是,子类型的属性比父类型更多、更具体:
interface Animal {
age: number
}
interface Dog extends Animal {
bark(): void
}
interface Animal {
age: number
}
interface Dog extends Animal {
bark(): void
}
在联合类型中需要注意父子类型的关系,因为确实有点「反直觉」。'a' | 'b' | 'c' 乍一看比 'a' | 'b' 的属性更多,那么 'a' | 'b' | 'c' 是 'a' | 'b' 的子类型吗?其实正反,'a' | 'b' | 'c' 是 'a' | 'b' 的父类型,因为前者包含的范围更广,而后者则更具体。
type Parent = "a" | "b" | "c";
type Child = "a" | "b";
let parent: Parent;
let child: Child;
parent = child // 兼容
child = parent // 不兼容,因为parent可能为'c',而child的类型里没有‘c’
type Parent = "a" | "b" | "c";
type Child = "a" | "b";
let parent: Parent;
let child: Child;
parent = child // 兼容
child = parent // 不兼容,因为parent可能为'c',而child的类型里没有‘c’
- 在类型系统中,属性更多的类型是子类型。
- 在集合论中,属性更少的集合是子集,因此在联合类型中属性更少的为子类型
- 父类型比子类型更宽泛,涵盖的范围更广,而子类型比父类型更具体
- 子类型一定可以赋值给父类型
extends
前面我们已经了解了父子类型。谈到父子类型,我们也能联想到在 TS 中经常用到的 extends 关键字。比如在 TS 的内置类型中,我们经常看到这样的代码:
type NonNullable<T> = T extends null | undefined ? never : T;
type Diff<T, U> = T extends U ? never : T;
type Filter<T, U> = T extends U ? T : never;
type NonNullable<T> = T extends null | undefined ? never : T;
type Diff<T, U> = T extends U ? never : T;
type Filter<T, U> = T extends U ? T : never;
extends 是一个 条件类型关键字, 下面的代码可以理解为:如果 T 是 U 的子类型,那么结果为 X,否则结果为 Y
T extends U ? X : Y
T extends U ? X : Y
只要理解了父类型和子类型,理解条件类型就非常容易
当 T 是联合类型时,叫做分布式条件类型(Distributive conditional types)。类似于数学中的因式分解: (a + b) * c = ac + bc
也就是说当 T 为 "A" | "B"
时, 会拆分成 ("A" extends U ? X : Y) | ("B" extends U ? X : Y)
type Diff<T, U> = T extends U ? never : T;
const demo: Diff<"a" | "b" | "d", "d" | "f">;
// result: "a" | "b"
type Diff<T, U> = T extends U ? never : T;
const demo: Diff<"a" | "b" | "d", "d" | "f">;
// result: "a" | "b"
"a"
不是"d" | "f"
的子集,取"a"
"b"
不是 "d" | "f" 的子集,取"b"
"d"
是"d" | "f"
的子集,取never
- 最后得出结果
"a" | "b"
协变
协变就是指上文中频频提到的“子类型可以赋值给父类型”, 在函数中的返回值是协变的。协变也可以通过鸭子类型来理解,即只要对象看起来像鸭子,走起来像鸭子,那么它就是鸭子:
让我们来看一段代码:
class Animal {
walk() {}
}
class Dog extends Animal {
bark() {}
}
let dogFunc: () => Dog = () => new Animal();
let animalFunc: () => Animal = () => new Dog();
class Animal {
walk() {}
}
class Dog extends Animal {
bark() {}
}
let dogFunc: () => Dog = () => new Animal();
let animalFunc: () => Animal = () => new Dog();
定义dogFunc函数时返回值类型为Dog,实现dogFunc函数时,返回值类型为Animal类型,返回值父类型不可以赋值给返回值子类型,报错❌。
定义animalFunc函数时返回值类型为Animal,实现animalFunc函数时,返回值类型为Dog类型,返回值子类型可以赋值给返回值父类型,正确✅。
逆变
从字面意思来看,逆变就是将类型的可变化规则倒过来,即父类型可以赋值给子类型,在ts中函数的参数具有逆变这种特性。
interface Animal {
walk(): void;
}
interface Dog extends Animal {
bark(): void;
}
let animalFunc: (animal: Animal) => void;
animalFunc = (animal: Animal) => {
animal.walk();
}
let dogFunc = (dog: Dog) => void;
dogFunc = (dog: Dog) => {
dog.bark();
}
dogFunc = animalFunc;
animalFunc = dogFuncl
interface Animal {
walk(): void;
}
interface Dog extends Animal {
bark(): void;
}
let animalFunc: (animal: Animal) => void;
animalFunc = (animal: Animal) => {
animal.walk();
}
let dogFunc = (dog: Dog) => void;
dogFunc = (dog: Dog) => {
dog.bark();
}
dogFunc = animalFunc;
animalFunc = dogFuncl
上面函数 animalFun 接收 Animal 类型的参数,而函数 dogFun 接收 Dog 类型的参数,现在将 animalFun 赋值给 dogFun,或者将 dogFun 赋值给 animalFun,编译器会报错吗?
发现将 animalFun 赋值给 dogFun 编译器并不会报错,而将 dogFun 赋值给 animalFun 编译器会提示错误。我们知道,animalFun 接收 Animal 类型的参数;dogFun 接收 Dog 类型的参数。并且 Dog 类型为 Animal 类型的子类型。
也就是说拥有父类型参数的函数可以
赋值给拥有子类型参数的函数;而拥有子类型参数的函数不可以
赋值给拥有父类型参数的函数。这种现象就是逆变。
相信看到这里大家已经理解了协变和逆变的,那么开篇引出问题我们按照两个点分析具体原因
- 首先ts会对代码做静态分析,它并不能拿到运行时的上下文,也就是说并不知道代码逻辑中
api
的类型和type
关联在了一起。既然它不能判断你在调用api
这个函数的时候它被推断成了联合类型中的哪一种,而且没有手动进行类型断言,于是,处在函数参数位置的类型发生逆变,处在函数返回值位置的类型发生协变 - 第二部分
(arg: number) => number | (arg: string) => string
的变化的结果其实是(arg: never) => string | number
,此时api调用时为了保证arg参数类型安全,arg逆变(父类型向子类型变化)得到number
和string
的共同子类 - 在 TypeScript 的这个 PR中有一句话:即在逆变位置的同一类型变量中的多个候选会被推断成交叉类型。
https://jkchao.github.io/typescript-book-chinese/tips/covarianceAndContravariance.html#一个有趣的问题
- 返回值位置是协变(子类型向父类型变化)得到父类型就是string | number。
因此参数位置得到的结果即 type T = string & number; // never
到这里这个问题就被我们分析透了。至于解决的办法也很简单,可以手动进行类型断言:
export const handleClick1 = (type: SourceType) => {
const [api] = ApiMap[type];
if (type === SourceType.Use_A) {
(api as Api_A)(1);
} else {
(api as Api_B)("str");
}
};
export const handleClick2 = (type: SourceType) => {
const [api] = ApiMap[type];
const assertApi_A = () => api as Api_A;
const assertApi_B = () => api as Api_B;
if (type === SourceType.Use_A) {
assertApi_A()(1);
} else {
assertApi_B()("str");
}
};
export const handleClick1 = (type: SourceType) => {
const [api] = ApiMap[type];
if (type === SourceType.Use_A) {
(api as Api_A)(1);
} else {
(api as Api_B)("str");
}
};
export const handleClick2 = (type: SourceType) => {
const [api] = ApiMap[type];
const assertApi_A = () => api as Api_A;
const assertApi_B = () => api as Api_B;
if (type === SourceType.Use_A) {
assertApi_A()(1);
} else {
assertApi_B()("str");
}
};
四、最后
协变意味着类型收窄,逆变意味着类型拓宽。
- 函数参数是逆变:父类型 -> 子类型
- 函数返回值是协变:子类型 -> 父类型
对于简单数据类型或结构(对象和类)类型而言,类型需要收窄到能确保它最安全的类型。对于函数的返回值同样如此。
只是对于函数的参数而言,参数类型应该拓宽到能确保它最安全的类型(比如至少得拥有相同的基类)。
一言以蔽之,如果函数作为回调参数,那么必须保证传入的这个回调函数的返回值的类型是协变的,而该回调函数的参数类型是逆变的。这样才能确保传入的回调函数是规定类型的同类型或其子类型。
函数更倾向于范围大的,参数是狗接收狗,参数是动物也能接收狗。 所以这事兼容允许的,但是反过来,狗不能接收其他动物。
从类型安全的角度能更好地理解层级关系,虽然型变的方向有所不同,但目的都是一样的。