跳到主要内容

逆变与协变

子类型

TypeScript中,如果A类型的值可以赋值给B类型的值,那么我们把A类型称为B类型的子类型,记作A extends B

机智的小伙伴能够很快发现extends主要被用在范型约束和条件类型当中,范型约束中extends把类型参数T的类型限制为给定类型的子类型,条件类型中A extends B ? C : D表示当AB的子类型是返回true分支的类型。

例子一:🌰

字面量类型是其对应类型的子类型。如'name' extends string100 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;

对于上方的AB类型,我们需要先判定二者的值能否相互赋值,才能知道谁是谁的子类型。

首先我们尝试把a赋值给b,之后再调用b.id.toFixed(),很明显能看出在运行时会报错,因为a并不存在id字段。所以a不是b的子类型。

b = a // wrong
b.id.toFixed() // 不存在id字段

接下来我们尝试把b赋值给a,之后再调用a上的方法,由于a的方法在b都存在,因此这是类型安全的。ab的子类型,记作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()

对于上方的代码我们已知BA的子类型,现在有一个范型Test<T>,根据例子二中相同的判定思路我们能够判断出d可以赋值给c,即 Test<B> Test<A>的子类型,记作Test<B> extends Test<A>

BA的子类型,而Test<B>又是Test<A>的子类型,所以我们称范型Test<T>的类型参数Tvalue: 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>的子类型。

BA的子类型,而Fn<B>又是Fn<A>的子类型,所以我们称范型Fn<T>的类型参数T在函数返回值这个位置是协变的。

逆变(contravariant)

先说结论,范型的类型参数在函数的参数位置上的逆变的。

给定范型Fn<T> = (arg: T) => void,如果BA的子类型,则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的最小边界值被设定为stringnumber的合集,即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 来说, Ustring(a)的子类型,同时也是string(b)的子类型,因此U的最大边界被限定为string

而对于type A 来说, Ustring(a)的子类型,同时也是number(b)的子类型,因此U的最大边界被限定stringnumber的交集,即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 ATest<A>Test<B>无法互相分配,即互不为子类型。