Skip to content

真正理解TS协变与逆变

一、协变、逆变和不变

协变和逆变都是术语,协变指的是能够使用比原来类型的派生程度更大(更具体的)的类型,逆变指的是能够使用比原来类型的派生程度更小(不太具体的)的类型 。 还有一种是不变,也有人称之为固定类型,也就是使用最初定义指定的类型。固定类型既不是协变也不是逆变。 在Typescript中函数支持协变和逆变,在分配和使用类型时提供更大的灵活性,函数的参数具有逆变的特性,返回值则具有协变的特性。所谓逆变指的是父类型可以赋给子类型。使用ts声明函数类型时函数参数类型是子类型,实际可以赋值的函数参数是父类型。 而协变指的是返回值的子类型可以赋值给父类型。

在过往的开发中遇到这样一个问题,下面是一个通过type在ApiMap中映射相关api的场景,相信有的同学在开发中可能会这样写,这段代码看着非常正常且合理,但是下面但是这样我们的编译器就会出现ts的报错,可是当我们遇到 api(type === SourceType.Use_A ? 1 : 'str') 类型推断却出了问题

typescript
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)。接口用于描述对象的形状,而不关心对象的具体实现或名称。只要对象的结构符合接口的定义,即可视为兼容。例如:

typescript
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没有关联 Untitled

三、型变

型变是指类型之间的转换关系,在 TypeScript 中分为协变、逆变、双向协变和不变。通过理解这些概念,可以更好地管理类型间的兼容性和约束。

父子类型

让我们来写一个简单的父子类型:

Dog 继承于 Animal,拥有比 Animal 更多的方法。因此我们说 Animal 是父类型,Dog 是它的子类型。需要注意的是,子类型的属性比父类型更多、更具体:

typescript
    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' 的父类型,因为前者包含的范围更广,而后者则更具体。

typescript
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 的内置类型中,我们经常看到这样的代码:

typescript
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

typescript
    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)

typescript
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"
  1. "a" 不是 "d" | "f" 的子集,取 "a"
  2. "b" 不是 "d" | "f" 的子集,取 "b"
  3. "d""d" | "f" 的子集,取 never
  4. 最后得出结果 "a" | "b"

协变

协变就是指上文中频频提到的“子类型可以赋值给父类型”, 在函数中的返回值是协变的。协变也可以通过鸭子类型来理解,即只要对象看起来像鸭子,走起来像鸭子,那么它就是鸭子:

让我们来看一段代码:

typescript
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();

Untitled 定义dogFunc函数时返回值类型为Dog,实现dogFunc函数时,返回值类型为Animal类型,返回值父类型不可以赋值给返回值子类型,报错❌。

定义animalFunc函数时返回值类型为Animal,实现animalFunc函数时,返回值类型为Dog类型,返回值子类型可以赋值给返回值父类型,正确✅。

逆变

从字面意思来看,逆变就是将类型的可变化规则倒过来,即父类型可以赋值给子类型,在ts中函数的参数具有逆变这种特性。

typescript
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,编译器会报错吗? Untitled

发现将 animalFun 赋值给 dogFun 编译器并不会报错,而将 dogFun 赋值给 animalFun 编译器会提示错误。我们知道,animalFun 接收 Animal 类型的参数;dogFun 接收 Dog 类型的参数。并且 Dog 类型为 Animal 类型的子类型。

也就是说拥有父类型参数的函数可以赋值给拥有子类型参数的函数;而拥有子类型参数的函数不可以赋值给拥有父类型参数的函数。这种现象就是逆变

相信看到这里大家已经理解了协变和逆变的,那么开篇引出问题我们按照两个点分析具体原因

  1. 首先ts会对代码做静态分析,它并不能拿到运行时的上下文,也就是说并不知道代码逻辑中api的类型和type关联在了一起。既然它不能判断你在调用 api 这个函数的时候它被推断成了联合类型中的哪一种,而且没有手动进行类型断言,于是,处在函数参数位置的类型发生逆变,处在函数返回值位置的类型发生协变
  2. 第二部分(arg: number) => number | (arg: string) => string的变化的结果其实是(arg: never) => string | number ,此时api调用时为了保证arg参数类型安全,arg逆变(父类型向子类型变化)得到numberstring的共同子类
  3. 在 TypeScript 的这个 PR中有一句话:即在逆变位置的同一类型变量中的多个候选会被推断成交叉类型。 Untitledhttps://jkchao.github.io/typescript-book-chinese/tips/covarianceAndContravariance.html#一个有趣的问题
  4. 返回值位置是协变(子类型向父类型变化)得到父类型就是string | number。

因此参数位置得到的结果即 type T = string & number; // never

到这里这个问题就被我们分析透了。至于解决的办法也很简单,可以手动进行类型断言:

typescript
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");
  }
};

四、最后

协变意味着类型收窄,逆变意味着类型拓宽

  • 函数参数是逆变:父类型 -> 子类型
  • 函数返回值是协变:子类型 -> 父类型

对于简单数据类型或结构(对象和类)类型而言,类型需要收窄到能确保它最安全的类型。对于函数的返回值同样如此。

只是对于函数的参数而言,参数类型应该拓宽到能确保它最安全的类型(比如至少得拥有相同的基类)。

一言以蔽之,如果函数作为回调参数,那么必须保证传入的这个回调函数的返回值的类型是协变的,而该回调函数的参数类型是逆变的。这样才能确保传入的回调函数是规定类型的同类型或其子类型。

函数更倾向于范围大的,参数是狗接收狗,参数是动物也能接收狗。 所以这事兼容允许的,但是反过来,狗不能接收其他动物。

从类型安全的角度能更好地理解层级关系,虽然型变的方向有所不同,但目的都是一样的。