TypeScript细碎知识点:类型兼容性

一、什么是类型兼容性

TS 的类型兼容性是指当一个类型的值可以被另一个类型的变量所接受时,这两种类型就是兼容的

集合论中,如果一个集合的所有元素在集合B中都存在,则A是B的子集;
类型系统中,如果一个类型的属性更具体,则该类型是子类型。(因为属性更少则说明该类型约束的更宽泛,是父类型)

因此,我们可以得出基本的结论:子类型比父类型更加具体,父类型比子类型更宽泛。 下面我们也将基于类型的可复制性(可分配性)、协变、逆变、双向协变等进行进一步的讲解。

🐹 可赋值性
interface Animal {
    name: string;
}
interface Dog extends Animal {
    break(): void;
}

let a: Animal = {
    name:'动物'
};
let b: Dog = {
    name:'🐶',
    break() {
        console.log('汪汪汪~');
    },
};

a = b; //🙆正确:可以赋值,子类型更佳具体,可以赋值给更佳宽泛的父类型
b = a; //🙅错误:反过来不行

✨ 可赋值性在联合类型中的特性

type A = 1 | 2 | 3;
type B = 2 | 3;
let a: A;
let b: B;

b = a;//🙅错误 不可赋值
a = b;//🙆正确:可以赋值

是不是A的类型更多,A就是子类型呢?恰恰相反,A此处类型更多但是其表达的类型更宽泛,所以A是父类型,B是子类型。

因此b = a不成立(父类型不能赋值给子类型),而a = b成立(子类型可以赋值给父类型)。

🐹 协变
/*接口*/
interface Animal {
    name: string;
}
interface Dog extends Animal {
    break(): void;
}

/*对象*/
let Eg1: Animal = {
    name: '动物'
};
let Eg2: Dog = {
    name: '🐶',
    break() {
        console.log('哇哇叫');
    }
};
Eg1 = Eg2; // 兼容,可以赋值

let Eg3: Array<Animal> = [Eg1]
let Eg4: Array<Dog> = [Eg2]
Eg3 = Eg4 // 兼容,也可以赋值

通过Eg3Eg4来看,在AnimalDog在变成数组后,Array<Dog>依旧可以赋值给Array<Animal>,因此对于type MakeArray = Array<any>来说就是协变的。

最后引用维基百科中的定义:

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

简单说就是,具有父子关系的多个类型,在通过某种构造关系构造成的新的类型,如果还具有父子关系则是协变的,而关系逆转了(子变父,父变子)就是逆变的。可能听起来有些抽象,下面我们将用更具体的例子进行演示说明:

🐹 逆变
/*接口*/
interface Animal {
    name: string;
}
interface Dog extends Animal {
    break(): void;
}

/*函数*/
type AnimalFn = (arg: Animal) => void
type DogFn = (arg: Dog) => void

let Eg1: AnimalFn;
let Eg2: DogFn;

// AnimalFn = DogFn不可以赋值了, Animal = Dog是可以的
Eg1 = Eg2; //🙅错误:不再可以赋值了,
Eg2 = Eg1; //🙆正确:反过来可以 

理论上,Animal = Dog是类型安全的,那么AnimalFn = DogFn也应该类型安全才对,为什么Ts认为不安全呢?看下面的例子:

let animal: AnimalFn = (arg: Animal) => {}
let dog: DogFn = (arg: Dog) => {
  arg.break();
}

// 假设类型安全可以赋值
animal = dog;
// 那么animal在调用时约束的参数,缺少dog所需的参数,此时会导致错误
animal({name: 'cat'});

从这个例子看到,如果dog函数赋值给animal函数,那么animal函数在调用时,约束的是参数必须要为Animal类型(而不是Dog),但是animal实际为dog的调用,此时就会出现错误。

因此,AnimalDog在进行type Fn<T> = (arg: T) => void构造器构造后,父子关系逆转了,此时成为“逆变”。

🐹 双向协变

Ts在函数参数的比较中实际上默认采取的策略是双向协变:只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。

这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息(典型的就是上述的逆变)。但是实际上,这极少会发生错误,并且能够实现很多JavaScript里的常见模式:

// lib.dom.d.ts中EventListener的接口定义
interface EventListener {
    (evt: Event): void;
}
// 简化后的Event
interface Event {
    readonly target: EventTarget | null;
    preventDefault(): void;
}
// 简化合并后的MouseEvent
interface MouseEvent extends Event {
    readonly x: number;
    readonly y: number;
}

// 简化后的Window接口
interface Window {
    // 简化后的addEventListener
    addEventListener(type: string, listener: EventListener)
}

// 日常使用
window.addEventListener('click', (e: Event) => { });
window.addEventListener('mouseover', (e: MouseEvent) => { });

可以看到Windowlistener函数要求参数是Event,但是日常使用时更多时候传入的是Event子类型。但是这里可以正常使用,正是其默认行为是双向协变的原因。可以通过tsconfig.js中修改strictFunctionType属性来严格控制协变和逆变。

🐹 敲重点!!!敲重点!!!敲重点!!!
  • infer推导的名称相同并且都处于逆变的位置,则推导的结果将会是交叉类型。

    type Bar<T> = T extends {
        a: (x: infer U) => void;
        b: (x: infer U) => void;
    } ? U : never;
    
    // type T1 = string
    type T1 = Bar<{ a: (x: string) => void; b: (x: string) => void }>;
    
    // type T2 = never
    type T2 = Bar<{ a: (x: string) => void; b: (x: number) => void }>;
  • infer推导的名称相同并且都处于协变的位置,则推导的结果将会是联合类型。

    type Foo<T> = T extends {
        a: infer U;
        b: infer U;
    } ? U : never;
    
    // type T1 = string
    type T1 = Foo<{ a: string; b: string }>;
    
    // type T2 = string | number
    type T2 = Foo<{ a: string; b: number }>;

posted on 2024-11-19 12:29  梁飞宇  阅读(94)  评论(0)    收藏  举报