联合类型与交叉类型

在原始类型与对象类型一节,我们了解了联合类型。但实际上,联合类型还有一个和它有点像的孪生兄弟:交叉类型。它和联合类型的使用位置一样,只不过符号是&,即按位与运算符。
实际上,正如联合类型的符号是|,它代表了按位或,即只需要符合联合类型中的一个类型,既可以认为实现了这个联合类型,如A | B,只需要实现 A 或 B 即可。
而代表着按位与的 & 则不同,你需要符合这里的所有类型,才可以说实现了这个交叉类型,即 A & B,需要同时满足 A 与 B 两个类型才行。
我们声明一个交叉类型:

  1. interface NameStruct {
  2. name: string;
  3. }
  4. interface AgeStruct {
  5. age: number;
  6. }
  7. type ProfileStruct = NameStruct & AgeStruct;
  8. const profile: ProfileStruct = {
  9. name: "linbudu",
  10. age: 18
  11. }

很明显这里的 profile 对象需要同时符合这两个对象的结构。从另外一个角度来看,ProfileStruct 其实就是一个新的,同时包含 NameStruct 和 AgeStruct 两个接口所有属性的类型。这里是对于对象类型的合并,那对于原始类型呢?

  1. type StrAndNum = string & number; // never

我们可以看到,它竟然变成 never 了!看起来很奇怪,但想想我们前面给出的定义,新的类型会同时符合交叉类型的所有成员,存在既是 string 又是 number 的类型吗?当然不。实际上,这也是 never 这一 BottomType 的实际意义之一,描述根本不存在的类型嘛。
对于对象类型的交叉类型,其内部的同名属性类型同样会按照交叉类型进行合并:

  1. type Struct1 = {
  2. primitiveProp: string;
  3. objectProp: {
  4. name: string;
  5. }
  6. }
  7. type Struct2 = {
  8. primitiveProp: number;
  9. objectProp: {
  10. age: number;
  11. }
  12. }
  13. type Composed = Struct1 & Struct2;
  14. type PrimitivePropType = Composed['primitiveProp']; // never
  15. type ObjectPropType = Composed['objectProp']; // { name: string; age: number; }

如果是两个联合类型组成的交叉类型呢?其实还是类似的思路,既然只需要实现一个联合类型成员就能认为是实现了这个联合类型,那么各实现两边联合类型中的一个就行了,也就是两边联合类型的交集:

  1. type UnionIntersection1 = (1 | 2 | 3) & (1 | 2); // 1 | 2
  2. type UnionIntersection2 = (string | number | symbol) & string; // string

总结一下交叉类型和联合类型的区别就是,联合类型只需要符合成员之一即可(||),而交叉类型需要严格符合每一位成员(&&)。

索引类型

索引类型指的不是某一个特定的类型工具,它其实包含三个部分:索引签名类型索引类型查询索引类型访问。目前很多社区的学习教程并没有这一点进行说明,实际上这三者都是独立的类型工具。唯一共同点是,它们都通过索引的形式来进行类型操作,但索引签名类型是声明,后两者则是读取。接下来,我们来依次介绍三个部分。

索引签名类型

索引签名类型主要指的是在接口或类型别名中,通过以下语法来快速声明一个键值类型一致的类型结构

  1. interface AllStringTypes {
  2. [key: string]: string;
  3. }
  4. type AllStringTypes = {
  5. [key: string]: string;
  6. }

这时,即使你还没声明具体的属性,对于这些类型结构的属性访问也将全部被视为 string 类型:

  1. interface AllStringTypes {
  2. [key: string]: string;
  3. }
  4. type PropType1 = AllStringTypes['linbudu']; // string
  5. type PropType2 = AllStringTypes['599']; // string

在这个例子中我们声明的键的类型为 string([key: string]),这也意味着在实现这个类型结构的变量中只能声明字符串类型的键

  1. interface AllStringTypes {
  2. [key: string]: string;
  3. }
  4. const foo: AllStringTypes = {
  5. "linbudu": "599"
  6. }

但由于 JavaScript 中,对于 obj[prop] 形式的访问会将数字索引访问转换为字符串索引访问,也就是说, obj[599] 和 obj[‘599’] 的效果是一致的。因此,在字符串索引签名类型中我们仍然可以声明数字类型的键。类似的,symbol 类型也是如此:

  1. const foo: AllStringTypes = {
  2. "linbudu": "599",
  3. 599: "linbudu",
  4. [Symbol("ddd")]: 'symbol',
  5. }

索引签名类型也可以和具体的键值对类型声明并存,但这时这些具体的键值类型也需要符合索引签名类型的声明:

  1. interface AllStringTypes {
  2. // 类型“number”的属性“propA”不能赋给“string”索引类型“boolean”。
  3. propA: number;
  4. [key: string]: boolean;
  5. }

这里的符合即指子类型,因此自然也包括联合类型:

  1. interface StringOrBooleanTypes {
  2. propA: number;
  3. propB: boolean;
  4. [key: string]: number | boolean;
  5. }

索引签名类型的一个常见场景是在重构 JavaScript 代码时,为内部属性较多的对象声明一个 any 的索引签名类型,以此来暂时支持对类型未明确属性的访问,并在后续一点点补全类型:

  1. interface AnyTypeHere {
  2. [key: string]: any;
  3. }
  4. const foo: AnyTypeHere['linbudu'] = 'any value';

索引类型查询

刚才我们已经提到了索引类型查询,也就是 keyof 操作符。严谨地说,它可以将对象中的所有键转换为对应字面量类型,然后再组合成联合类型。注意,这里并不会将数字类型的键名转换为字符串类型字面量,而是仍然保持为数字类型字面量

  1. interface Foo {
  2. linbudu: 1,
  3. 599: 2
  4. }
  5. // "linbudu" | 599
  6. type FooKeys = keyof Foo;

如果觉得不太好理解,我们可以写段伪代码来模拟 “从键名到联合类型” 的过程。

  1. type FooKeys = Object.keys(Foo).join(" | ");

除了应用在已知的对象类型结构上以外,你还可以直接 keyof any 来生产一个联合类型,它会由所有可用作对象键值的类型组成:string | number | symbol。也就是说,它是由无数字面量类型组成的,由此我们可以知道, keyof 的产物必定是一个联合类型

索引类型访问

avaScript 中我们可以通过 obj[expression] 的方式来动态访问一个对象属性(即计算属性),expression 表达式会先被执行,然后使用返回值来访问属性。而 TypeScript 中我们也可以通过类似的方式,只不过这里的 expression 要换成类型。接下来,我们来看个例子:

  1. interface NumberRecord {
  2. [key: string]: number;
  3. }
  4. type PropType = NumberRecord[string]; // number

这里,我们使用 string 这个类型来访问 NumberRecord。由于其内部声明了数字类型的索引签名,这里访问到的结果即是 number 类型。注意,其访问方式与返回值均是类型。
更直观的例子是通过字面量类型来进行索引类型访问:

  1. interface Foo {
  2. propA: number;
  3. propB: boolean;
  4. }
  5. type PropAType = Foo['propA']; // number
  6. type PropBType = Foo['propB']; // boolean

看起来这里就是普通的值访问,但实际上这里的’propA’和’propB’都是字符串字面量类型而不是一个 JavaScript 字符串值。索引类型查询的本质其实就是,通过键的字面量类型(’propA’)访问这个键对应的键值类型(number)
看到这你肯定会想到,上面的 keyof 操作符能一次性获取这个对象所有的键的字面量类型,是否能用在这里?当然,这可是 TypeScript 啊。

  1. interface Foo {
  2. propA: number;
  3. propB: boolean;
  4. propC: string;
  5. }
  6. // string | number | boolean
  7. type PropTypeUnion = Foo[keyof Foo];

使用字面量联合类型进行索引类型访问时,其结果就是将联合类型每个分支对应的类型进行访问后的结果,重新组装成联合类型。索引类型查询、索引类型访问通常会和映射类型一起搭配使用,前两者负责访问键,而映射类型在其基础上访问键值类型,我们在下面一个部分就会讲到。
注意,在未声明索引签名类型的情况下,我们不能使用 NumberRecord[string] 这种原始类型的访问方式,而只能通过键名的字面量类型来进行访问。

  1. interface Foo {
  2. propA: number;
  3. }
  4. // 类型“Foo”没有匹配的类型“string”的索引签名。
  5. type PropAType = Foo[string];

索引类型的最佳拍档之一就是映射类型,同时映射类型也是类型编程中常用的一个手段。

映射类型:类型编程的第一步


不同于索引类型包含好几个部分,映射类型指的就是一个确切的类型工具。看到映射这个词你应该能联想到 JavaScript 中数组的 map 方法,实际上也是如此,映射类型的主要作用即是基于键名映射到键值类型。概念不好理解,我们直接来看例子:

  1. type Stringify<T> = {
  2. [K in keyof T]: string;
  3. };

这个工具类型会接受一个对象类型(假设我们只会这么用),使用 keyof 获得这个对象类型的键名组成字面量联合类型,然后通过映射类型(即这里的 in 关键字)将这个联合类型的每一个成员映射出来,并将其键值类型设置为 string。
具体使用的表现是这样的:

  1. interface Foo {
  2. prop1: string;
  3. prop2: number;
  4. prop3: boolean;
  5. prop4: () => void;
  6. }
  7. type StringifiedFoo = Stringify<Foo>;
  8. // 等价于
  9. interface StringifiedFoo {
  10. prop1: string;
  11. prop2: string;
  12. prop3: string;
  13. prop4: string;
  14. }

看起来好像很奇怪,我们应该很少会需要把一个接口的所有属性类型映射到 string?这有什么意义吗?别忘了,既然拿到了键,那键值类型其实也能拿到:

  1. type Clone<T> = {
  2. [K in keyof T]: T[K];
  3. };

这里的T[K]其实就是上面说到的索引类型访问,我们使用键的字面量类型访问到了键值的类型,这里就相当于克隆了一个接口。需要注意的是,这里其实只有K in 属于映射类型的语法,keyof T 属于 keyof 操作符,[K in keyof T]的[]属于索引签名类型,T[K]属于索引类型访问。

类型工具 创建新类型的方式 常见搭配
类型别名 将一组类型/类型结构封装,作为一个新的类型 联合类型、映射类型
工具类型 在类型别名的基础上,基于泛型去动态创建新类型 基本所有类型工具
联合类型 创建一组类型集合,满足其中一个类型即满足这个联合类型(||) 类型别名、工具类型
交叉类型 创建一组类型集合,满足其中所有类型才满足映射联合类型(&&) 类型别名、工具类型
索引签名类型 声明一个拥有任意属性,键值类型一致的接口结构 映射类型
索引类型查询 从一个接口结构,创建一个由其键名字符串字面量组成的联合类型 映射类型
索引类型访问 从一个接口结构,使用键名字符串字面量访问到对应的键值类型 类型别名、映射类型
映射类型 从一个联合类型依次映射到其内部的每一个类型 工具类型