逆变与协变
子类型
在TypeScript中,如果A类型的值可以赋值给B类型的值,那么我们把A类型称为B类型的子类型,记作A extends B。
机智的小伙伴能够很快发现extends主要被用在范型约束和条件类型当中,范型约束中extends把类型参数T的类型限制为给定类型的子类型,条件类型中A extends B ? C : D表示当A是B的子类型是返回true分支的类型。
例子一:🌰
字面量类型是其对应类型的子类型。如'name' extends string、100 extends number
declare let a: 'name';
declare let b: string;
b = a; // ok a是b的子类型
a = b; // wrong 
例子二:🌰
type A = {
    name: string;
    age: number;
}
type B = A & {
    id: number
}
declare let a: A;
declare let b: B; 
对于上方的A和B类型,我们需要先判定二者的值能否相互赋值,才能知道谁是谁的子类型。
首先我们尝试把a赋值给b,之后再调用b.id.toFixed(),很明显能看出在运行时会报错,因为a并不存在id字段。所以a不是b的子类型。
b = a // wrong
b.id.toFixed() // 不存在id字段
接下来我们尝试把b赋值给a,之后再调用a上的方法,由于a的方法在b都存在,因此这是类型安全的。a是b的子类型,记作a extends b
a = b; // ok
a.name.toString() 
我们平时也可以根据这个思路来判断两个值是否可以相互赋值,进而判断对应类型之间的关系。
例子三:🌰
对于联合类型来说,string extends string | number,其他情况类似。
declare let a: string | number;
declare let b: string;
a = b; // ok
b = a; // wrong
协变(covariant)
先看例子。
例子四:🌰
type A = {
    name: string;
    age: number;
}
type B = A & {
    id: number
}
declare let a: A;
declare let b: B; 
type Test<T> = {
    value: T;
}
declare let c: Test<A>
declare let d: Test<B>
d = c; // wrong
d.value.id.toFixed() // 不存在id字段
c = d; // ok
c.value.name.toString()
对于上方的代码我们已知B是A的子类型,现在有一个范型Test<T>,根据例子二中相同的判定思路我们能够判断出d可以赋值给c,即 Test<B> 是Test<A>的子类型,记作Test<B> extends Test<A> 。
B是A的子类型,而Test<B>又是Test<A>的子类型,所以我们称范型Test<T>的类型参数T在value: T这个位置是协变的。
例子五:🌰
type A = {
    name: string;
    age: number;
}
type B = A & {
    id: number
}
declare let a: A;
declare let b: B; 
type Fn<T> = () => T
declare let c: Fn<A>
declare let d: Fn<B>
d = c; // wrong
d().id.toFixed() // 不存在id字段
c = d; // ok
c().name.toString()
同样的,在本例中有存在范型Fn<T> = () => T,我们用相同的判定思路判断出Fn<B>是Fn<A>的子类型。
B是A的子类型,而Fn<B>又是Fn<A>的子类型,所以我们称范型Fn<T>的类型参数T在函数返回值这个位置是协变的。
逆变(contravariant)
先说结论,范型的类型参数在函数的参数位置上的逆变的。
给定范型Fn<T> = (arg: T) => void,如果B是A的子类型,则Fn<B>是Fn<A>的父类型。
例子六:🌰
type A = {
    name: string;
    age: number;
}
type B = A & {
    id: number
}
declare let a: A;
declare let b: B; 
type Fn<T> = (arg: T) => void;
let c: Fn<A> = (arg: A) => console.log(arg.name.toString())
let d: Fn<B> = (arg: B) => console.log(arg.id.toFixed())
c = d;
c(a) // wrong 运行时函数内部访问arg.id报错
d = c;
d(b) // ok 运行时函数内部访问arg.name和arg.age都是安全的
来点类型体操
例子七:🌰
type Foo<T> = T extends { a: infer U, b: infer U } ? U : never;
type A = Foo<{ a: string, b: string }>;  // string
type B = Foo<{ a: string, b: number }>;  // string | number
对于上方的范型Foo<T>,观察可知类型参数U所在的两个位置都是协变的,并且T是{ a: infer U, b: infer U}的子类型。
因此对于type A = Foo<{ a: string, b: string}>来说,string(a)是U的子类型,string(b)是U的子类型,因此U的最小边界值被限定为string。
而对于type B = Foo<{ a: string, b: number}>来说,string(a)是U的子类型,number(b)是U的子类型,因此U的最小边界值被设定为string和number的合集,即A | B。
速记:同一个类型参数在协变位置上的多个候选将会推导成联合类型
例子八:🌰
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type A = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
type B = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number
对于上方的范型Bar<T>,观察可知类型参数U所在的两个位置都是逆变的。
因此对于type A 来说, U是string(a)的子类型,同时也是string(b)的子类型,因此U的最大边界被限定为string。
而对于type A 来说, U是string(a)的子类型,同时也是number(b)的子类型,因此U的最大边界被限定string和number的交集,即A & B。
速记:同一个类型参数在逆变位置上的多个候选将会推导成交叉类型
双向协变(Bivariant)
首先有一点我们需要格外注意,TypeScript中有两种方式声明对象的方法。
// Object method
interface One<T> {
    fn(arg: T): void;
}
// Property with function type
interface Two<T> {
    fn: (arg: T) => void
}
这两种写法几乎没有任何差别,除了一点。范型Two<T>的T在参数位置是逆变的,但One<T>的T在参数位置是双向协变的!
那么,什么是双向协变呢?简单来说,对于B extends A,那么这里的One<B> extends One<A>和One<A> extends One<B>是同时成立的。
可以看出双向协变相较于逆变来说是更加宽松也更加不安全,所以通常来说对象方法的定义我们应该采取第二种方式。
不变(Invariant)
简单来说,对于B extends A,Test<A>和Test<B>无法互相分配,即互不为子类型。