超集与子集
TypeScript
是JavaScript
的类型的超集。超集,不得不说另外一个概念,子集,怎么理解这两个呢?
举个例子,如果一个集合A
里面的所有元素集合B
里面都存在,那么我们可以理解集合B
是集合A
的超集,集合A
为集合B
的子集。
这段话也许有些绕口,下面用代码举例,可以更加直观理解。
interface Animal {
age: number
}
interface Dog extends Animal {
bark(): void
}
复制代码
在这个例子中,Animal
是Dog
的父类,Dog
是Animal
的子类型,子类型的属性比父类型更多,更具体。
在类型系统中,属性更多的类型是子类型。在集合论中,属性更少的集合是子集。也就是说,子类型是父类型的超集,而父类型是子类型的子集。
记住一个特征,子类型比父类型更加具体,这点很关键。
可赋值性 assignable
assignable
是类型系统中很重要的一个概念,当你把一个变量赋值给另一个变量时,就要检查这两个变量的类型之间是否可以相互赋值。
let animal: Animal
let dog: Dog
animal = dog // ✅
dog = animal // ❌error! animal 实例上缺少属性 'bark'
复制代码
从这个例子里可以看出,animal
是一个「更宽泛」的类型,它的属性比较少,所以更「具体」的子类型是可以赋值给它的。因为animal
上只有age
这个属性的,dog
拥有animal
所拥有的一切类型,赋值给animal
是不会出现类型安全问题的。
反之,如果dog = animal
,那么后续使用者期望dog
上拥有bark
属性,当调用了dog.bark()
就会引发运行时的崩溃。
从可赋值性角度来说,子类型是可以赋值给父类型的,也就是父类型变量 = 子类型变量是安全的,因为子类型上涵盖了父类型所拥有的的一切属性。
T extends {}
,为什么可以extends
一个空类型并且在传递任意类型时都成立呢?当搞明白上面的知识点,这个问题也自然迎刃而解了。
举例手写Exclude
源码,可以理解更通透。
// T 中的类型如果是 U 的子集,返回 never,否则返回 T
type myExclude<T, U extends keyof any> = T extends U ? never : T;
const text: myExclude<number | string | boolean, number> = "this is myExclude";
复制代码
理解了超集与子集、可赋值性,以及他们之间的关系,接下来再去理解逆变和协变,可以更加通透。
逆变
有这样一段代码:
let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;
fn1 = fn2; // ❌ TS Error: 不能将fn2的类型赋值给fn1
复制代码
思考:明明fn2
的形参包括了所有的fn1
的形参为什么会报错?
调用fn1
支持两个参数的传入,调用fn2
支持三个参数的传入,假设fn2
赋值给fn1
成功了,那么调用fn1
时,其实相等于调用fn2
。
如果执行了fn1 = fn2
,当调用fn1
时明显参数个数不匹配(由于类型定义不一致)会缺少第三个参数,显然是不安全的,是不被TS
允许的。
反过来呢?
let fn1!: (a: string, b: number) => void;
let fn2!: (a: string, b: number, c: boolean) => void;
fn2 = fn1; // ✅
复制代码
按照上述思路,将fn1
赋值给fn2
,此时fn2
内部指针已经被修改为fn1
的指针。
调用fn1
时只需要两个参数a: string, b: number
,显然fn2
的类型定义是满足该条件的。(在JS中函数调用时的实参个数大于定义时的形参个数是被允许的)。
所以,这是安全的可以被TS
允许赋值。
通俗来说,上述函数参数少(父)的可以赋值给参数多(子)的,这种参数类型兼容性是典型的逆变。
换个方式来加深理解:
如果
T ≤ U
,那么F<U> ≤ F<T>
成立,这就叫逆变。
class Animal {
doAnimalThing() {
console.log("do animal thing.");
}
}
class Dog extends Animal {
doDogThing() {
console.log("do dog thing.");
}
}
// Cotra<Animal> ≤ Cotra<Dog>
type Cotra<V> = (input: V) => void;
const animalFn: Cotra<Animal> = (input) => {
input.doAnimalThing();
};
const dogFn: Cotra<Dog> = (input) => {
input.doDogThing();
};
let a: Cotra<Animal> = dogFn; // ❌ Animal 没有 doDogThing 方法
let b: Cotra<Dog> = animalFn; // ✅
复制代码
方法a
定义入参为Animal
,赋值是dogFn
,调用方法a
时如果传入Animal
,由于Animal
没有doDogThing
方法,一定会执行出错。所以这里TS
会提示错误。
但反过来就没问题。方法b
传入Dog
,Dog
继承Animal
,是有doAnimalThing
方法的。
协变
let fn1!: (a: string, b: number) => string;
let fn2!: (a: string, b: number) => string | number | boolean;
fn2 = fn1; // ✅
fn1 = fn2 // ❌ error: 不可以将 string|number|boolean 赋给 string 类型
复制代码
函数类型赋值兼容时函数返回值就是典型的协变场景。
根据上述代码,显然string | number | boolean
联合类型是无法分配给string
基本类型的,但是string
是满足string | number | boolean
其中之一,所以可以赋值给string | number | boolean
组成的联合类型。
从函数运行角度来看,fn1 = fn2
相当于调用了fn2
自然string | number | boolean
无法满足string
类型的要求,所以TS
会认为这是错误的。
换个方式来加深理解:
如果
T ≤ U
,那么F<T> ≤ F<U>
也成立,这就叫协变。
class Animal {
doAnimalThing() {
console.log("do animal thing.");
}
}
class Dog extends Animal {
doDogThing() {
console.log("do dog thing.");
}
}
// Co<Dog> ≤ Co<Animal>
type Co<T> = () => T;
const animalFn: Co<Animal> = () => {
return new Animal();
};
const dogFn: Co<Dog> = () => {
return new Dog();
};
let a: Co<Animal> = dogFn; // ✅ dogFn 返回 Dog,Dog 本身就是 Animal
let b: Co<Dog> = animalFn; // ❌ animalFn 返回 Animal,Animal 不一定是 Dog,无法执行 doDogThing
复制代码
函数的返回值类型要协变才安全,否则 TS 可能会报错。
协变与逆变的作用
首先,是为了保证类型安全
其次,就是允许类型拥有一定的灵活性而不是死板的。
参考文章:
www.icodebang.com/article/276…
mp.weixin.qq.com/s/3rRrpMyQq…