前言

TypeScript 中有很多中央波及到子类型 subtype、父类型 supertype、协变 Covariant 、逆变 Contravariant、双向协变 Bivariant 和不变 Invariant的概念,如果搞不清这些概念,那么很可能被报错搞的无从下手,或者在写一些简单类型的时候看到他人能够这么写,然而不晓得它的原因。

extends关键字

在TypeScript中,extends关键字在不同利用场景下有以下三种含意:

  1. 示意继承/拓展的含意:

继承父类的办法和属性

class Animal {  public weight: number = 0  public age: number = 0}class Dog extends Animal {  public wang() {    console.log('汪!')  }  public bark() {}}class Cat extends Animal {  public miao() {    console.log('喵~')  }}

继承类型

interface Animal {  age: number}interface Dog extends Animal {  bark: () => void} // Dog => { age: number; bark(): void }
  1. 示意束缚的含意

在书写泛型的时候,咱们往往须要对类型参数作肯定的限度,比方心愿传入的参数都有 name 属性的数组咱们能够这么写:

function getCnames<T extends { name: string }>(entities: T[]):string[] {  return entities.map(entity => entity.cname)}
  1. 示意调配的含意(可赋值性 assignable
type Animal = {  name: string;}type Dog = {  name: string;  bark: () => void}type Bool = Dog extends Animal ? 'yes' : 'no'; // 'yes'

以下重点介绍示意调配含意,也就是可赋值性的一些用法

简略的值匹配

type Equal<X, Y> = X extends Y ? true : false;type Num = Equal<1, 1>; // truetype Str = Equal<'a', 'a'>; // truetype Boo1 = Equal<true, false>; // falsetype Boo2 = Equal<true, boolean>; // true

类型X能够调配给类型Y,而不是说类型X是类型Y的子集

never

它天然被调配的一些例子:

  • 一个从来不会有返回值的函数(如:如果函数内含有 while(true) {});
  • 一个总是会抛出谬误的函数(如:function foo() { throw new Error('Not Implemented') }foo 的返回类型是 never);

never是所有类型的子类型

type A = never extends 'x' ? string : number; // stringtype P<T> = T extends 'x' ? string : number;type B = P<never> // string

简单类型值匹配

class Animal {  public weight: number = 0  public age: number = 0}class Dog extends Animal {  public wang() {    console.log('wang')  }  public bark() {}}class Cat extends Animal {  public miao() {    console.log('miao')  }}type Equal<X, Y> = X extends Y ? true : false;type Boo = Equal(Dog, Animal) // truetype Boo = Equal(Animal, Dog) // false

type Boo = Equal(Animal, Dog) // false 这是因为 Animal 没有bark属性,类型Animal不满足类型Dog的类型束缚。因而,A extends B,是指类型A能够调配给类型B,而不是说类型A是类型B的子集,了解extends在类型三元表达式里的用法十分重要。

父子类型

还是以动物类做比喻:

interface Animal {  age: number}interface Dog extends Animal {  bark: () => void}let animal: Animallet dog: Dog

在这个例子中,AnimalDog 的父类,DogAnimal的子类型,子类型的属性比父类型更多,更具体。

  • 在类型零碎中,属性更多的类型是子类型。
  • 在集合论中,属性更少的汇合是子集。

也就是说,子类型是父类型的超集,而父类型是子类型的子集,这是直觉上容易搞混的一点。

记住一个特色,子类型比父类型更加具体,这点很要害。

上述例子中能够看出,animal 是一个「更宽泛」的类型,它的属性比拟少,所以更「具体」的子类型是能够赋值给它的,因为你是晓得 animal 上只有 age 这个属性的,你只会去应用这个属性,dog 上领有 animal 所领有的所有类型,赋值给 animal 是不会呈现类型平安问题的。

反之,如果 dog = animal,那么后续使用者会冀望 dog 上领有 bark 属性,当他调用了 dog.bark() 就会引发运行时的解体。

从可赋值性角度来说,子类型是能够赋值给父类型的,也就是 父类型变量 = 子类型变量 是平安的,因为子类型上涵盖了父类型所领有的的所有属性。

当我初学的时候,我会感觉 T extends {} 这样的语句很奇怪,为什么能够 extends 一个空类型并且在传递任意类型时都成立呢?当搞明确下面的知识点,这个问题也天然迎刃而解了。

到这里为止,算是根本讲完了extends的三种用法,以下进入正题:逆变协变、双向协变和不变


缘起

ts写久了,有次在为某个组件写props类型的时候须要传一个onClick的工夫函数类型时忽然有个问题涌现脑海:

为什么在interface外面定义函数类型都是写成函数属性而不是办法,即:

interface Props {  handleClick: (arg: string) => number   // 广泛写法  handleClick(arg: string): number  // 非主流写法}

终于在typescript-eslint中看到规定集时遇到了这个规定

@typescript-eslint/method-signature-style

规定案例如下:

❌ Incorrect

interface T1 {  func(arg: string): number;}type T2 = {  func(arg: boolean): void;};interface T3 {  func(arg: number): void;  func(arg: string): void;  func(arg: boolean): void;}

✅ Correct

interface T1 {  func: (arg: string) => number;}type T2 = {  func: (arg: boolean) => void;};// this is equivalent to the overloadinterface T3 {  func: ((arg: number) => void) &    ((arg: string) => void) &    ((arg: boolean) => void);}

A method and a function property of the same type behave differently. Methods are always bivariant in their argument, while function properties are contravariant in their argument under strictFunctionTypes.

雷同类型的办法和函数属性的行为不同。办法在它们的参数中总是双变的,而函数属性在严格性能类型下的参数中是逆变的。

看到这句话后也是一脸懵逼,第一次见到双向协变和逆变这两个词,于是查阅材料找到了他们的概念以及延长的协变和不变

逆变协变

先来段维基百科的定义:

协变与逆变(covariance and contravariance)是在计算机科学中,形容具备父/子型别关系的多个型别通过型别结构器、结构出的多个复杂型别之间是否有父/子型别关系的用语。

咦,父/子型别关系后面如同也提到过,而后说起逆变和协变,又要提到后面说的可调配性,这也就是为什么文章结尾要花大篇幅去介绍extends关键字的起因,在ts中决定类型之间的可调配性是基于结构化类型(structural typing)的

协变(Covariance)

那么设想一下,当初咱们别离有这两个子类型的数组,他们之间的父子关系应该是怎么样的呢?没错,Animal[] 仍然是 Dog[] 的父类型,对于这样的一段代码,把子类型赋值给父类型仍然是平安的:

let animals: Animal[]let dogs: Dog[]animals = dogsanimals[0].age // ✅ok

转变成数组之后,对于父类型的变量,咱们仍然只会去找 Dog 类型中肯定有的那些属性(因为子类型更加具体,父类型有的属性子类型都有)

那么,对于 type MakeArray<T> = T[] 这个类型结构器来说,它就是 协变(Covariance) 的。

逆变(Contravariance)

逆变的确比拟难懂,先做一个有(无)趣(聊)的题(起源:《深刻了解TypeScript》)

开始做题之前咱们先约定如下的标记:

  • A ≼ B 意味着 AB 的子类型。
  • A → B 指的是以 A 为参数类型,以 B 为返回值类型的函数类型。
  • x : A 意味着 x 的类型为 A

问题:以下哪种类型是 Dog → Dog 的子类型呢?

  1. Greyhound → Greyhound
  2. Greyhound → Animal
  3. Animal → Animal
  4. Animal → Greyhound

让咱们来思考一下如何解答这个问题。首先咱们假如 f 是一个以 Dog → Dog 为参数的函数。它的返回值并不重要,为了具体形容问题,咱们假如函数构造体是这样的: f : (Dog → Dog) → String

当初我想给函数 f 传入某个函数 g 来调用。咱们来瞧瞧当 g 为以上四种类型时,会产生什么状况。

1. 咱们假如 g : Greyhound → Greyhoundf(g) 的类型是否平安?

不平安,因为在f内调用它的参数(g)函数时,应用的参数可能是一个不同于灰狗但又是狗的子类型,例如 GermanShepherd (牧羊犬)。

2. 咱们假如 g : Greyhound → Animalf(g) 的类型是否平安?

不平安。理由同(1)。

3. 咱们假如 g : Animal → Animalf(g) 的类型是否平安?

不平安。因为 f 有可能在调用完参数之后,让返回值,也就是 Animal (动物)狗叫。并非所有动物都会狗叫。

4. 咱们假如 g : Animal → Greyhoundf(g) 的类型是否平安?

是的,它的类型是平安的。首先,f 可能会以任何狗的种类来作为参数调用,而所有的狗都是动物。其次,它可能会假如后果是一条狗,而所有的灰狗都是狗。

也就是说:在对类型别离调用如下的类型结构器之后:

type MakeFunction<T> = (arg: T) => void

父子类型关系逆转了(用下面的题来了解:Animal → Greyhound是Dog -> Dog的子类型,然而Animal却是Dog的父类型),这就是 逆变(Contravariance)

通过 这个例子能够发现:

  • 返回值 -> 协变(Greyhound -> Dog)
  • 入参通常应该为逆变(Animal <- Dog)

函数属性与函数办法

理解了这两个概念之后咱们能够大抵猜想双向协变和不变的定义,双向协变那就是又能够协变又能够逆变,不变反之,既不能协变也不能逆变,当初咱们先到之前困惑的中央:interface Props{}外面为什么倡议用函数属性的写法定义函数类型?

先理解一个知识点:这两种写法到底有什么区别?

// 尝试在tsconfig.ts => compilerOptions => 增加/移除"strictFunctionTypes": true这条规定,别离查看以下后果interface Animal {  name: string}interface Dog extends Animal {  wang: () => void}interface Cat extends Animal {  miao: () => void}// 这里能够类比咱们说的Props接口定义interface Comparer<T> {  compare(a: T, b: T): number  // compare: (a: T, b: T) => number;}declare let animalComparer: Comparer<Animal>declare let dogComparer: Comparer<Dog>animalComparer = dogComparer // ???dogComparer = animalComparer // Ok

咱们来看看以下这两种编译成js的后果区别是什么?

class Handler {  constructor() {    this.handleDebounce();    this.handleThrottle();  }  handleDebounce() {    console.log('防抖函数', this);  }  handleThrottle = () => {    console.log('节流函数', this);  }}let handler = new Handler();

Compile.... =>

var Handler = /** @class */ (function () {    function Handler() {        var _this = this;        this.handleThrottle = function () {            console.log('节流函数', _this);        };               this.handleDebounce();        this.handleThrottle();    }    Handler.prototype.handleDebounce = function () {        console.log('防抖函数', this);    };    return Handler;}());var handler = new Handler();

能够看到handleDebounce被写入了原型对象上,原生对象的通用办法也写在了原型,实际上typescript的这种行为是为了兼容js的行为,兼顾开发体验。

再用官网的两个例子再次阐明这个问题:

declare let f1: (x: Animal) => void;declare let f2: (x: Dog) => void;declare let f3: (x: Cat) => void;f1 = f2;  // Error with --strictFunctionTypesf2 = f1;  // Okf2 = f3;  // Error

第一个赋值在默认类型查看模式下是容许的,但在严格函数类型模式下被标记为谬误。直觉上,默认模式容许赋值,因为它可能是正当的,而严格函数类型模式使它成为一个谬误,因为它不能证实是正当的。在任何一种模式下,第三个赋值都是谬误的,因为它永远不会是正当的。

形容示例的另一种形式是,类型在默认类型查看模式下(x: T) => void变的(即协变逆变)T,但在严格函数类型模式下是逆变T

interface Comparer<T> {    compare(a: T, b: T): number;}declare let animalComparer: Comparer<Animal>;declare let dogComparer: Comparer<Dog>;animalComparer = dogComparer;  // Ok because of bivariancedogComparer = animalComparer;  // Ok

--strictFunctionTypesmode 中,依然容许第一个赋值,因为它compare被申明为一个办法。实际上,T是双变的,Comparer<T>因为它仅用于办法参数地位。然而,更改compare为具备函数类型的属性会导致更严格的查看失效:

interface Comparer<T> {    compare: (a: T, b: T) => number;}declare let animalComparer: Comparer<Animal>;declare let dogComparer: Comparer<Dog>;animalComparer = dogComparer;  // ErrordogComparer = animalComparer;  // Ok

论断:在严格模式下(或者strictFunctionTypes):类型平安问题将失去保障,与之相同的是默认双向协变将可能使得你在应用类型的时候变得不平安!

Array

先抛出一个问题:List<Dog> 是否为 List<Animal> 的子类型?(起源:《深刻了解TypeScript》)

先看上面这个例子:

interface Animal {  name: string}interface Dog extends Animal {  wang: () => void}interface Cat extends Animal {  miao: () => void}const dogs: Array<Dog> = []const animals: Animal[] = dogs// Array在ts中是双向协变animals.push(new Cat())

如果列表是不可变的(immutable),那么答案是必定的,因为类型很平安。然而如果列表是可变的,那么答案相对是否定的!

可变数据

如果翻看typescript的Array的类型,能够看到Array类型定义写的是函数办法,因而,它的入参是双向协变的!

interface Array<T> {    length: number;    toString(): string;    toLocaleString(): string;    pop(): T | undefined;    push(...items: T[]): number;    concat(...items: ConcatArray<T>[]): T[];    concat(...items: (T | ConcatArray<T>)[]): T[];    join(separator?: string): string;    reverse(): T[];    shift(): T | undefined;    slice(start?: number, end?: number): T[];    sort(compareFn?: (a: T, b: T) => number): this;    splice(start: number, deleteCount?: number): T[];    splice(start: number, deleteCount: number, ...items: T[]): T[];    unshift(...items: T[]): number;    indexOf(searchElement: T, fromIndex?: number): number;    lastIndexOf(searchElement: T, fromIndex?: number): number;    every(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;    some(callbackfn: (value: T, index: number, array: T[]) => boolean, thisArg?: any): boolean;    forEach(callbackfn: (value: T, index: number, array: T[]) => void, thisArg?: any): void;    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];    filter<S extends T>(callbackfn: (value: T, index: number, array: T[]) => value is S, thisArg?: any): S[];    filter(callbackfn: (value: T, index: number, array: T[]) => any, thisArg?: any): T[];    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;    reduce(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;    reduce<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T): T;    reduceRight(callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T, initialValue: T): T;    reduceRight<U>(callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U, initialValue: U): U;    [n: number]: T;}

可变数组+双向协变无奈保障类型平安

更平安的数组类型

interface MutableArray<T> {        length: number;    toString(): string;    toLocaleString(): string;    pop: () => T | undefined;    push: (...items: T[]) =>  number;    concat:(...items: ConcatArray<T>[]) => T[];    join: (separator?: string) => string;    reverse: () => T[];    shift:() => T | undefined;    slice:(start?: number, end?: number) => T[];    sort:(compareFn?: (a: T, b: T) => number) => this;    indexOf: (searchElement: T, fromIndex?: number) => number;      // ...}

此时咱们会发现MutableArray其实是个不可变类型,不再能相互调配

const dogs: MutableArray<Dog> = [] as Dog[];// errorconst animals: MutableArray<Animal> = dogs;const animals: MutableArray<Animal> = [] as Animal[] ;// errorconst dogs: MutableArray<Dog> = animals

起因是Array类型既存在逆变办法push也存在协变办法pop,甚至还有不可变办法concat

总结

  • 能够应用readonly来标记属性,使其不可变
  • 更多地应用函数属性而不是函数办法来定义类型
  • 尝试让类型中的协变或者逆变离开,或者让类型不可变
  • 尽可能防止双向协变

参考资料

[1]@typescript-eslint/method-signature-style: https://github.com/typescript...

[2]PR: https://github.com/microsoft/...