TS 的协变、逆变、双协变和类型体操

171 阅读9分钟

1、协变、逆变、双协变

类型安全和型变

TypeScript 给 JavaScript 添加了一套静态类型系统,是为了保证类型安全的,也就是保证变量只能赋同类型的值,对象只能访问它有的属性、方法

比如 number 类型的值不能赋值给 boolean 类型的变量,Date 类型的对象就不能调用 exec 方法

这是类型检查做的事情,遇到类型安全问题会在编译时报错

但是这种类型安全的限制也不能太死板,有的时候需要一些变通,比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用,也就是“型变”(类型改变)

这种“型变”分为两种,一种是子类型可以赋值给父类型,叫做协变,一种是父类型可以赋值给子类型,叫做逆变

协变

其中协变是很好理解的,比如我们有两个 interface:

interface Person {
  name: string;
  age: number;
} 

interface Son {
  name: string;
  age: number;
  hobbies: string[]
}

这里 Son 是 Person 的子类型,更具体,那么 Son 类型的变量就可以赋值给 Person 类型:

let person: Person = {
  name: '人类',
  age:20
}

let childrenPeople: Son = {
  name: '人类',
  age: 20,
  hobbies:['吃饭','睡觉','打豆豆']
}

person = childrenPeople

这并不会报错,虽然这俩类型不一样,但是依然是类型安全的

这种子类型可以赋值给父类型的情况就叫做协变

为什么要支持协变很容易理解:类型系统支持了父子类型,那如果子类型还不能赋值给父类型,还叫父子类型么?

所以型变是实现类型父子关系必须的,它在保证类型安全的基础上,增加了类型系统的灵活

逆变【主要是函数赋值的时候会发生】

我们有这样两个函数:

interface Person {
  name: string;
  age: number;
} 

interface Son {
  name: string;
  age: number;
  hobbies: string[]
}

let printHobbies: (paramsSon: Son) => void;

// 函数一
printHobbies = (paramsSon) => {
    console.log(paramsSon.hobbies);
}

let printName: (person: Person) => void;

// 函数二
printName = (person) => {
    console.log(person.name);
}

printHobbies 的参数 paramsSon 是 printName 参数 Person 的子类型

那么问题来了,printName 能赋值给 printHobbies 么?printHobbies 能赋值给 printName 么?

测试一下发现是这样的:

image.png

printName 的参数 Person 不是 printHobbies 的参数 paramsSon 的父类型么,为啥能赋值给子类型?

因为函数声明的时候是按照 paramsSon 来约束类型,但是调用的时候是按照 Person 的类型来访问的属性和方法,当然不会有问题,依然是类型安全的

这就是逆变,函数的参数有逆变的性质(而返回值是协变的,也就是子类型可以赋值给父类型)

那反过来呢,如果 printHoobies 赋值给 printName 会发生什么?

因为函数声明的时候是按照 Person 来约束类型,但是调用的时候是按照 paramsSon 的类型来访问的属性和方法,那自然类型不安全了,所以就会报错

双向协变

但是在 ts2.x 之前支持这种赋值,也就是父类型可以赋值给子类型,子类型可以赋值给父类型,既逆变又协变,叫做“双向协变”

但是这明显是有问题的,不能保证类型安全,所以之后 ts 在 ts.config 文件中添加加了一个编译选项 strictFunctionTypes,设置为 true 就只支持函数参数的逆变,设置为 false 则是双向协变

我们把 strictFunctionTypes 关掉之后,就会发现两种赋值都可以了:

image.png

这样就支持函数参数的双向协变,类型检查不会报错,但不能严格保证类型安全

ts中类型父子关系的判断

像 java 里面的类型都是通过 extends 继承的,如果 A extends B,那 A 就是 B 的子类型。这种叫做名义类型系统(nominal type)

而 ts 里不看这个,只要结构上是一致的,那么就可以确定父子关系,这种叫做结构类型系统(structual type)

还是拿上面那个例子来说:

image.png

类型 Son 和 Person 有 extends 的关系么?

没有呀!!! 那是怎么确定父子关系的?

通过结构,更具体的那个是子类型。这里的 Son 有 Person 的所有属性,并且还多了一些属性,所以 Son 是 Person 的子类型

注意,这里用的是更具体,而不是更多

判断联合类型父子关系的时候, 'a' | 'b' 和 'a' | 'b' | 'c' 哪个更具体?

'a' | 'b' 更具体,所以 'a' | 'b' 是 'a' | 'b' | 'c' 的子类型

总结

ts 通过给 js 添加了静态类型系统来保证了类型安全,大多数情况下不同类型之间是不能赋值的,但是为了增加类型系统灵活性,设计了父子类型的概念。父子类型之间自然应该能赋值,也就是会发生型变

型变分为逆变和协变。协变很容易理解,就是子类型赋值给父类型。逆变主要是函数赋值的时候函数参数的性质,参数的父类型可以赋值给子类型,这是因为按照子类型来声明的参数,访问父类型的属性和方法自然没问题,依然是类型安全的。但反过来就不一定了

不过 ts 2.x 之前反过来依然是可以赋值的,也就是既逆变又协变,叫做双向协变

为了更严格的保证类型安全,ts 添加了 strictFunctionTypes 的编译选项,开启以后函数参数就只支持逆变,否则支持双向协变

型变都是针对父子类型来说的,非父子类型自然就不会型变也就是不变

ts 中父子类型的判定是按照结构来看的,更具体的那个是子类型

2、类型体操

在网上看了一圈资料,类型体操的大致思路就是:
通过extends结合三元运算符去判断与选择,通过never去过滤,通过keyof、obj[key] 去提取等等,通过这些运算最终得到更细粒度的类型值

大佬写的TypeScript 类型体操通关秘籍,但是要花钱,他喵的

keyof

keyof 返回一个类型的所有 key 的联合类型:

type KEYS = keyof {
    a: string,
    b: number
} // a|b

类型索引

类型索引可以通过 key 来获取对应 value 的类型:

type Value = {a: string, b: number}['a'] // string

特别的,使用 array[number] 可以获取数组/元组中所有值类型的联合类型:

type Values = ['a', 'b', 'c'][number] // 'a'|'b'|'c'

in 操作符与类型映射

in 操作符有点类似于值操作中的 for in 操作,可以遍历联合类型,结合类型索引可以从一个类型中生成一个新的类型:

// 从 T 中 pick 出一个或多个 key 组成新的类型
type MyPick<T, S extends keyof T> = {
    [R in S] : T[R]
} 
type PartType = MyPick<{a: string, b: number, c: number}, 'a'|'b'> // {a: string, b: number}

同样,数组类型也可以遍历,R in keyof T 的结果为数组的下标:

type ArrayIndex<T extends any[]> = {
    [R in keyof T]: R
}
// 的到一个数组下标组成的新数组类型
type Indexes = ArrayIndex<['a', 'b', 'c']> // ['0', '1', '2']

extends

extends 类型于值运算符中的三元表达式:

S extends T ? K : V

若 S 兼容 T 则返回类型 K 否则返回类型 V,例如:

type Whether = "a" extends "a"|"b" ? true : false // true
type Whether2 = {a: string} extends {b: string} ? true : false // false

extends 中有一个重要的概念为类型分发,例如:

type Filter<T, S> = T extends S ? never : T
type X = Filter<'a'|'b'|'c', 'c'> // 'a'|'b'

从直观上来看 Filter 的作用是计算 'a'|'b'|'c' extends 'c' 这个表达式显然不成立,应该返回 never。

但是实际上返回了 'a'|'b'。这是由于当 extends 需要检测的类型为泛型联合类型时,会将联合类型中的每一个类型分别进行检测。

因此 'a'|'b'|'c' extends 'c' 实际等价于:

'a' extends 'c' ? never : T | 'b' extends 'c' ? never : T | 'c' extends 'c' ? never : T 

结果返回 'a' | 'b' | never ,由于 never 是最低级的类型,在联合类型里面会自动删除,最终变成 'a' | 'b'

ts中类型的优先级:
1anyunknown 即顶级类型  
2Object  
3NumberStringBoolean  
4numberstringboolean  
5123'123'false  
6、nerver  
unknown类型的数据,只能赋值给自身或者是any  
unknown类型的数据,无法获取对象中的属性或者是方法  
never类型的数据,表示不应该存在或者永远无法达到的状态数据类型(很抽象呀)

// 没有一个数据既是number类型又是string类型,所以a的类型既是never
type a = number & string
 
// 在联合类型中,never类型由于是最底层的类型,会被ts自动忽略,所以a的类型既是number | void
type a = number | void | never

这里也包含了另外一个知识点,xxx|never=xxx。可以将联合类型与 extends 结合使用达到循环的效果。如果要阻止类型分发,只需要在外面套一个数组即可:

type Filter<T, S> = T extends S ? never : T
type X = Filter<'a'|'b'|'c', 'c'> // 'a'|'b'

type Filter<T, S> = [T] extends [S] ? never : T
type X = Filter<'a'|'b'|'c', 'c'> // never

如果很多时候我们既需要类型分发后的类型,还需要类型分发前的联合类型。例如如果我们判断一个类型是否为联合类型,那么可以:

type IsUnion<T> = T extends T ? [Exclude<T, T>] extends [never] ? false: true : never

即如果一个类型是联合类型,那么 execlude 掉一个其中的类型后其类型不会为 never。否则就为 never。但是在 Exclude 中出现了两 T 这明显是不行的。因此可以利用 TS 的默认类型:

type IsUnion<T, R=T> = T extends any ? [Exclude<R, T>] extends [never] ? false: true : never

这种方法可以用在既需要分发后的类型也需要原始类型的情况。

此外,extends 还有另一个需要注意的地方,泛型变量无法直接与 never 比较,需要套一个数组,例如:

type IsNever<T> = T extends never ? true: false
type Y = IsNever<never> // never

type IsNever<T> = [T] extends [never] ? true: false
type Y = IsNever<never> // true

infer

infer 可以类比到值元算的类型匹配,在类型体操中有非常多的应用

type ExtractType<T> = T extends {a: infer R} ? R : never // 匹配成功返回 R 否则返回 never
ExtractType<{a: {b: string}}> // {b: string}

可以看出先定义了一个模板 {a: infer R} 然后用于匹配类型 {a: {b: string}},这时就可以得到 R = {b: string}。目前 infer 出来的类型仅能应用到 extends 的成功分支
infer 也可以用匹配字面量的类型,例如:

type Startswith<T, S extends string> = T extends `${S}${infer R}` ? true : false
Startswith<"hello world", "hello"> // true
Startswith<"hello world", "world"> // false

type Strip<T, S extends string> = T extends `${S}${infer R}` ? R : T
type Y1 = Strip<"hello world", "hello "> // world
type Y2 = Strip<"hello world", "world"> // hello world

数组/元组类型

数组类型可以使用 ... 操作符进行展开:

type Add<S extends any[], R> = [...S, R]
type Y3 = Add<[1, 2, 3], 4> // [1, 2, 3, 4]

元组表示不可修改的数组,可以使用 as const 将数组转换为元组。

const array1 = [1, 2, 3, 4]
type X1 = typeof array1 // number[]
type X2 = X1[number] // number

const array2 = [1, 2, 3, 4] as const
type Y1 = typeof array2 // readonly [1, 2, 3, 4]
type Y2 = Y1[number] // 1|2|3|4

递归类型

在 typescript 类型操作符中不存在循环表达式,但是可以使用递归来进行循环操作,例如:

type TrimLeft<T extends string> = T extends ` ${infer R}`? TrimLeft<R>: T
type Y7 = TrimLeft<'  Hello World  '> // Hello World  

type Concat<S extends any[]> = S extends [infer R, ...infer Y] ? `${R & string}${Concat<Y>}` : ''
type Y6 = Concat<['1', '2', '3']>

type Join<S extends any[], T extends string> = S extends [infer R, ...infer Y] ? 
                                                (Y['length'] extends 0 ? R: `${R & string}${T}${Join<Y, T>}`)  : ''
                                               
type Y4 = Join<['1', '2', '3'], '-'> // '1-2-3'


type Flatten<S extends any[]> = S extends [infer R, ...infer Y] ? 
    (R extends any[] ? [...Flatten<R>, ...Flatten<Y>] : [R, ...Flatten<Y>]) : []
type Y3 = Flatten<[[1], 2, [3, 4, [5], [6, [7, 8]]]]> // [1, 2, 3, 4, 5, 6, 7 ,8]