类型兼容:如何判断一个类型是否可以赋值给其他类型

584 阅读12分钟

在TypeScript中,能不能把一个类型赋值给其他类型时有类型兼容性决定的。

特性

any, never, unknown类型

any

any可以赋值给除了never之外的任意其他类型,反过来其他类型也可以赋值给any。 也就是说any可以兼容除never之外的所有类型, 同时也可以被所有其他类型兼容(即any既是top type,有时bottom type)。

never

never的特性是可以赋值给任何其他类型,但反过来不行(包括any在内),即never是bottom type。

let never: never = (() => {
    throw Error('never')
})()

let a: number = never; // ok
let b: () => any = never; // ok
let c: {} = never; // ok

unknown

unknown的特性和never的特性几乎是反过来,即我们不能把unknown赋值给除了any之外任何其他类型, 反过来其他类型都可赋值给unknown(即unknown时top type)。

let unknwon: known;

const a: number = unknown; //ts2322
const b: () => any = unknown; //ts2322
const c: {} = unknown; // ts2322

void, null, undefined

void仅可以赋值给any,unknown类型,反过来any,never,undefined可以赋值给void.

{
  let thisIsAny: any;
  let thisIsNever: never;
  let thisIsUnknown: unknown;
  let thisIsVoid: void;
  let thisIsUndefined: undefined;
  let thisIsNull: null;
  thisIsAny = thisIsVoid; // ok
  thisIsUnknown = thisIsVoid; // ok
  thisIsVoid = thisIsAny; // ok
  thisIsVoid = thisIsNever; // ok
  thisIsVoid = thisIsUndefined; // ok
  // thisIsVoid = thisIsNull; // ts2322
  thisIsAny = thisIsNull; // ok
  thisIsUnknown = thisIsNull; // ok
  thisIsAny = thisIsUndefined; // ok
  thisIsUnknown = thisIsUndefined; // ok
  
  thisIsNull = thisIsAny; // ok
  thisIsNull = thisIsNever; // ok
  thisIsUndefined = thisIsAny; // ok
  thisIsUndefined = thisIsNever; // ok
}   

严格模式下,null,undefined表现出于void类型的兼容性,即只能赋值给any,unknown,反过来其他类型(除了any和never之外)都不可以赋值给null或undefined

enum

数字枚举和数字类型相互兼容。

enum A {
    one
}

// A.one 默认的数值为1,与number兼容
let one: number = A.one; //ok
let fun = (param: A) => void 0;
fun(1); // ok

不同枚举之间不兼容

enum A {
    one
}

enum B {
   one
}

let a : A;
let b: B;
a = b; // ts2322
b = a; // ts2322

类型兼容性

除了前面提到的所有特例, TypeScript中类型的兼容性都是基于结构化/子类型的一般原则进行判定的。

子类型

从子类型角度来看, 所有的子类型与它的父类型都兼容。 如下:

const one = 1;
let num:number = one; // ok

interface IPar {
    name: string;
}

interface IChild extends IPar {
    id: number;
}

let Par: IPar;
let Child: IChild;
Par = Child; // ok

class CPar {
    name = '';
}
class CChild extends CPar {
    cid = 1;
}

let ParInst:CPar;
let ChildInst:CChild;
ParInst = ChildInst; // ok
let mixedNum: 1 | 2 | 3 = one; // ok

成员类型兼容它所属的类型集合(其实联合类型枚举都算类型集合,这里主要说的是联合类型),对应的,由子类型组成的联合类型也可以额兼容它们父类型组成的联合类型

let ICPar: IPar | CPar;
let ICChild: IChild | CChild;
ICPar = ICChild; // ok

结构类型

类型兼容性的另一准则是结构类型(类或接口类型), 即如果两个类型的结构一致,则它们是互相兼容的。 比如拥有相同的属性,方法的接口类型或类。

class C1 {
    name = '1'
}

class C2 {
    name = '2'
}



interface I1 {
    name: string;
}

interface I2 {
    name: string;
}
let InstC1: C1;
let InstC2: C2;

let O1: I1;
let O2: I2;
InstC1 = InstC2; // Ok
O1 = O2; // ok
InstC1 = O1; // ok
O2 = InstC2; // ok
包含/继承兼容

两个接口类型或者类,如果其中一个类型不仅拥有另外一个类型全部的属性和方法,还包含其他的属性和方法(如同继承自另外一个类型的子类一样),那么前者是可以兼容后者的。

interface I1 {
    name: sting;
}

interface I2 {
    id: number;
    name: string;
}

class C2 {
    id = 1;
    name = '1';
}

let O1: I1;
let O2: I2;
let InstC2:C2;
O1 = O2;
O1 = InstC2;
对象字面量的freshness特性

虽然包含多余属性id的变量O2可以赋值给变量O1, 但是如果我们直接将一个与变量O2完全一样结构的对象字面量赋值给变量O1,则会提示一个ts2322类型不兼容的错误, 这就是对象字面量的freshness特性。

也就是说一个对象字面量被变量接收时,它处于这一种freshness状态。 这时TypeScript会对对象字面量的赋值操作进行严格的类型检测,只有目标变量的类型与对象字面量的类型完全一致时,对象字面量才会赋值给目标变量,否则会提示类型错误。我们可以通过变量或者类型断言解除freshness。 如下:

// 直接将对象字面量赋值给变量O1(I1类型)
O1 = {
    id: 123, // ts 2322
    name: 'NAME'
}

// 定义变量
let O3 = {
    id: 123,
    name: 'name'
}
O1 = O3; // 变量赋值
O1 = {
    id: 123,
    name: 'name'
} as I2; // 类型断言

忽略构造函数,静态属性和方法 / 私有受保护属性, 方法需源自同一个类

在判断两个类是否兼容时,我们可以完全忽略其构造函数及静态属性和方法是否兼容, 只需要比较类实例的属性和方法是否兼容即可,如果两个类包含私有,受保护的属性和方法,则仅当这些属性和方法源自同一个类, 它们才兼容

{
    class C1 {
        name = '1';
        private id = 1;
        protected age = 10;
    }
    
    class C2 {
        name = '2';
        private id = 2;
        protected age = 20;
    }
    
   let instC1: C1;
   let instC2: C2;
   instC1 = instC2; // 不能将类型C2赋值给C1, 类型具有私有属性“id”的单独声明 ts2322
   // 注释掉private属性声明
   instC2 = instC1; // 不能将类型属性"C1"赋值给类型"C2". 属性“age”受保护, 但类型C1并不是从C2派生。 ts2322
}

{
    class CPar {
        private id = 1;
        protect age = 10;
        
    }
    
    class C1 extends CPar {
        constructor() {
            super()
        }
        
        name = '1';
        static gender = 'man'
    }
    
    class C2 extends CPar {
        constructor() {
            super()
        }
        
        name = '2';
        static gender = 'woman'
    }
    
    let instC1: C1;
    let instC2: C2;
    
   instC1 = instC2; // ok
   instC2 = instC1; // ok
}

可继承和可实现

类型兼容还决定了接口类型和类是否可以通过extends继承另外一个接口类型或者类, 以及类是否可以通过implements实现接口。

interface I1 {
    name: number;
}
interface I2 extends I1{ // ts 2430
    name: string
}

class C1 {
    name = 1;
    private id = 1;
}
class C2 extends C1 { // ts 2415
    name = '2';
    private id = 1;
}
class C3 implements I1 {
    name = ''; //ts 2416
}

泛型

泛型类型, 泛型类型的间通信实际值得是讲他们实例化为一个确切的类型后的兼容性(且入参只有作为实例化后的类型的一部分才能影响类型兼容性。

{
    interface I1<T> {
        id: number;
    }
    interface I2<T> {
        prop: T
    }

    let O1: I1<number>
    let O2: I1<string>
    O1 = O2; // ok

    let O3: I2<number>
    let O4: I2<string>
    O3 = O4; // ts 2322
}

函数的类型兼容

TypeScript中的变型指的是根据类型之前的子类型关系推断基于它们构造的更复杂类型之间的例子类型关系。使用数学中函数的表达方式描述类型和基于类型构造的复杂类型之的关系,比如F(Animal)表示基于Animal构造的复杂类型。

基于Dog和Animal之间的子类型关系, 从而得出F(Dog) 和 F(Animal)之间的子类型关系的一般性质。 这个性质体现为子类型关系可能会被保持、反转、忽略, 因此他可以被划分为协变、逆变、双向协变、不变4个专业术语。

协变

协变: 如果Dog是Animal的子类型,则F(Dog)是F(Animal)的子类型, 意味着在构造的复杂类型中保持了一直的子类型关系。

{

  type isChild<Child, Par> = Child extends Par ? true : false;

  interface Animal {
    name: string;
  }

  interface Dog extends Animal {
    woof: () => void;
  }
  
  // 构造复杂类型的工具函数
  type Covariance<T> = T;
  type isCovariant = isChild<Covariance<Dog>, Covariance<Animal>>; // true

}

实际上接口类型的属性、数组类型、函数返回值的类型都是协变的,下面看一个具体的示例:

type isPropAssignmentCovariant = isChild<{ type: Dog }, { type: Animal }>; // true
type isArrayElementCovariant = isChild<Dog[], Animal[]>; // true
type isReturnTypeCovariant = isChild<() => Dog, () => Animal>; // true

逆变

逆变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的父类型,这与协变正好反过来。

实际场景中,在我们推崇的TypeScript 严格模式下,函数参数类型是逆变的,具体示例如下:

type Contravariance<T> = (param: T) => void;
type isNotContravariance = isChild<Contravariance<Dog>, Contravariance<Animal>>; // false;
type isContravariance = isChild<Contravariance<Animal>, Contravariance<Dog>>; // true;

为了便于理解,我们可以从安全性的角度理解函数参数是逆变的设定

如果函数参数类型是协变而不是逆变,那么意味着函数类型(param: Dog) => void(param: Animal) => void是兼容的,这与Dog和Animal的兼容性一致,所以我么可以用(param: Dog) => void 代替(param: Animal) => void 遍历Animal[]类型数组。

但这是不安全的,因为它不能确保Animal[]数组中的成员都是Dog(可能混入Animal类型的其他子类型,比如Cat),这就会导致(param: Dog) => void 类型的函数可能接受到Cat类型的入参。示例:

const visitDog = (animal: Dog) => {
    animal.woof();
}

let animals: Animal[] = [{name: 'cat', mial: () => void 0}]

animals.forEach(visitDog) // ts2345

正是因为函数参数是逆变的,所以使用visitDog函数遍历Animal[]类型数组时,提示了类型错误。

双向协变

双向协变也就是说如果 Dog 是 Animal 的子类型,则 F(Dog) 是 F(Animal) 的子类型,也是父类型,既是协变也是逆变。

对应到实际的场景,在 TypeScript 非严格模式下,函数参数类型就是双向协变的。如前边提到函数只有在参数是逆变的情况下才安全,且本课程一直在强调使用严格模式,所以双向协变并不是一个安全或者有用的特性,因此我们不大可能遇到这样的实际场景

但在某些资料中有提到,如果函数参数类型是双向协变,那么它是有用的,并进行了举例论证 (以下示例缩减自网络):

  interface Event {
    timestamp: number;
  }

  interface MouseEvent extends Event {
    x: number;
    y: number;
  }

  function addEventListener(handler: (n: Event) => void) {}
  addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // ts(2769)

这种方式确实方便了很多,但是并不安全,原因见前边 Dog 和 Cat 的示例。而且在严格模式下,参数类型是逆变而不是双向协变的,

由此可以得出,真正有用且安全的做法是使用泛型,如下所示:

  function addEventListener<E extends Event>(handler: (n: E) => void) {}
  addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // ok

不变

不变即只要是不完全一样的类型,它们一定是不兼容的。也就是说即便 Dog 是 Animal 的子类型,如果 F(Dog) 不是 F(Animal) 的子类型,那么 F(Animal) 也不是 F(Dog) 的子类型。

对应到实际场景,出于类型安全层面的考虑,在特定情况下我们可能希望数组是不变的(实际上是协变),见示例:

  interface Cat extends Animal {
    miao: () => void; 
  }

  const cat: Cat = {
    name: 'Cat',
    miao: () => void 0,
  };

  const dog: Dog = {
    name: 'Dog',
    woof: () => void 0,
  };

  let dogs: Dog[] = [dog];
  animals = dogs; // ok
  animals.push(cat); // ok
  dogs.forEach(visitDog); // 类型 ok,但运行时会抛出错误

因此,对于可变的数组而言,不变似乎是更安全、合理的设定。不过,在 TypeScript 中可变、不变的数组都是协变的,这是需要我们注意的一个陷阱。

介绍完变型相关的术语以及对应的实际场景,我们已经了解了函数参数类型是逆变的,返回值类型是协变的,所以前面的函数类型 (p1: any) => 1 和 (param: any) => number 为什么兼容的问题已经给出答案了。

因为返回值类型 1 是 number 的子类型,且返回值类型是协变的,所以 (p1: any) => 1 是 (param: any) => number 的子类型,即是兼容的。

函数类型兼容性

因为函数类型的兼容性、子类型关系有着更复杂的考量(它还需要结合参数和返回值的类型进行确定),所以下面我们详细介绍一下函数类型兼容性的一般规则。

(1)返回值

前边我们已经讲过返回值类型是协变的,所以在参数类型兼容的情况下,函数的子类型关系与返回值子类型关系一致。也就是说返回值类型兼容,则函数兼容。

(2)参数类型

前边我们也讲过参数类型是逆变的,所以在参数个数相同、返回值类型兼容的情况下,函数子类型关系与参数子类型关系是反过来的(逆变)。

(3)参数个数

在索引位置相同的参数和返回值类型兼容的前提下,函数兼容性取决于参数个数,参数个数少的兼容个数多,下面我们看一个具体的示例:

{

  let lessParams = (one: number) => void 0;
  let moreParams = (one: number, two: string) => void 0;
  lessParams = moreParams; // ts(2322)
  moreParams = lessParams; // ok
}

在示例中,lessParams 参数个数少于 moreParams,所以如第 5 行所示 lessParams 和 moreParams 兼容,并可以赋值给 moreParams。

注意:如果你觉得参数个数少的函数兼容参数个数多的函数不好理解,那么可以试着从安全性角度理解(是参数少的函数赋值给参数多的函数安全,还是参数多的函数赋值给参数少的函数安全),这里限于篇幅有限就不展开了(你可以作为思考题)。

(4)可选和剩余参数

可选参数可以兼容剩余参数、不可选参数,下面我们具体看一个示例:

  let optionalParams = (one?: number, tow?: number) => void 0;
  let requiredParams = (one: number, tow: number) => void 0;
  let restParams = (...args: number[]) => void 0;
  requiredParams = optionalParams; // ok
  restParams = optionalParams; // ok
  optionalParams = restParams; // ts(2322)
  optionalParams = requiredParams; // ts(2322)
  restParams = requiredParams; // ok
  requiredParams = restParams; // ok

最让人费解的是,在第 8 行中,把不可选参数 requiredParams 赋值给剩余参数 restParams 其实是不安全的(但是符合类型检测),我们需要从方便性上理解这个设定。

正是基于这个设定,我们才可以将剩余参数类型函数定义为其他所有参数类型函数的父类型,并用来约束其他类型函数的类型范围,比如说在泛型中约束函数类型入参的范围。

下面我们看一个具体的示例:

type GetFun<F extends (...args: number[]) => any> = Parameters<F>;
type GetRequiredParams = GetFun<typeof requiredParams>;
type GetRestParams = GetFun<typeof restParams>;
type GetEmptyParams = GetFun<() => void>;

在示例中的第 1 行,我们使用剩余参数函数类型 (...args: number[]) => any 约束了入参 F 的类型,而第 2~4 行传入的函数类型入参都是这个剩余参数函数类型的子类型。

参考链接: