详解 TypeScript 中的交叉类型

简介

交叉类型逻辑上与[[联合类型]]是互补的,交叉类型由一组有序的成员类型构成,交叉类型表示类型同时为多个类型,等同于运算符 &&,类似于数学运算中的乘法。交叉类型也是通过交叉类型字面量来定义的。

交叉类型字面量

交叉类型由两个及以上的成员类型构成,成员类型通过 & 符号分隔。

下面是一个交叉类型字面量的例子:

interface Clickable {
 click(): void
}
interface Focusable {
 focus(): void
}
type T = Clickable & Focusable

这里定义了一个名为 T 的交叉类型,由 ClickableFocusable 两个成员类型组成,表示 T 既是 Clickable 类型,又是 Focusable 类型。

成员类型可以为任意类型

交叉类型和[[联合类型]]相似,其成员类型可以为任意类型,例如:布尔类型、字符串类型、字符串数组类型、函数类型、空对象类型字面量、对象类型。

不同的是:当成员类型为原始类型的交叉类型时,结果类型为 never [[尾端类型]]。

详解 TypeScript 中的交叉类型

这里 T1…T6 都是 never 类型,这一点比较好理解,因为不存在一种类型同时为多个原始类型,TypeScript 中不存在的类型就是 never 类型。

成员类型合并

若成员类型有相同类型,则相同类型会被合并为一个。

这里 T1 由两个 string 类型构成,成员类型相同合并成了一个 string 类型。

详解 TypeScript 中的交叉类型

T2 由三个 string 类型构成,成员类型相同合并成了一个 string 类型。

详解 TypeScript 中的交叉类型

满足“加法交换律”

成员类型是有序的但是与顺序无关满足加法交换律,交互成员类型先后顺序不影响结果类型。

注意:交叉类型的加法交换律性质对签名重载不起作用,下文会讲到

下例中交叉类型 T1、T2 都由顺序不同的相同成员类型构成,最终结果类型相同。

T1 的结果类型

详解 TypeScript 中的交叉类型

T2 的结果类型

详解 TypeScript 中的交叉类型

签名重载不满足“加法交换律”

[[函数签名]]包括调用签名、构造签名,当交叉类型的结果类型含有多个调用签名或构造签名时即构成了签名重载,分别称为调用签名重载构造签名重载

当交叉类型的结果类型涉及到调用签名重载、构造签名重载时,上述“加法交换律”将不生效,即成员类型的顺序会影响重载签名的顺序。

这里,ClickableFocusable 都包含 hello 这一调用签名定义,因此交叉类型 T1 和 T2 涉及到调用签名重载, t1.hello() 返回值是 stringt2.hello() 返回值是 number,可见成员类型顺序影响签名重载。

并且,签名重载的顺序与成员类型的顺序一致,这里 let a = t1.hello() 取的是 Clickablehello(): string 这一签名重载,故返回值是 string

详解 TypeScript 中的交叉类型

满足“加法结合律”

成员类型满足加法结合律,使用分组运算符()不影响交叉类型的结果类型。

下例中交叉类型都由相同成员类型构成,对不同成员类型使用分组运算符,最终结果类型相同。

interface Clickable {
    hello(): string
 click(): void
}
interface Focusable {
    hello(): number
 focus(): void
}
interface Scrollable {
    scroll(): void
}
type T1 = (Clickable & Focusable) & Scrollable

type T2 = Clickable & (Focusable & Scrollable)

declare const t1T1

declare const t2T2

T1 类型的值

详解 TypeScript 中的交叉类型

T2 类型的值

详解 TypeScript 中的交叉类型

交叉类型的类型成员

上面提到交叉类型的成员类型可以为任意类型,当成员类型为基础类型时交叉类型结果类型为 never 类型,当成员类型为对象类型时交叉类型的结果类型会怎样呢?

交叉类型的类型成员由其成员类型决定,规则简要描述为:交叉类型的类型成员由各个类型成员的属性成员的并集组成,属性成员的类型为各个成员类型的交叉类型

对象类型分为对象类型字面量和接口,下面以对象类型字面量来详细描述并集的产生过程。

类型成员的并集

以这段代码为例

interface Clickable {
    hello(): string
 click(): void
}
interface Focusable {
    hello(): number
 focus(): void
}
type T = Clickable & Focusable

代码执行结果见下图,c1 满足 Clickable 类型约束,是 Clickable 类型,但并不能赋值给交叉类型 T,提示说“缺少交叉类型上的必须属性 foucs”;c2 同时满足 Clickable Focusable 类型约束, 能赋值给交叉类型 T。

详解 TypeScript 中的交叉类型

可见,交叉类型 T 的结果类型的属性成员是成员类型 ClickableFocusable 类型各自属性成员的并集。

对象字面面类型或接口类型同时具有属性签名、索引签名、调用签名、构造签名,下面看看各签名在遇到交叉类型时如何处理。

属性签名

交叉类型的结果类型如上面分析的对象、接口的属性成员一样,是并集。

索引签名

索引签名的概念在[[联合类型]]中有讲到

索引签名的语法形式如下:

{
 [keynumber]: string;
}
接口
interface List {
 [keynumber]: any;
}

索引签名分为数字索引签名、字符串索引签名,如上代码所示,其作用是用来描述使用索引访问对象的属性的类型。像 obj.a obj[key] list[0] list[n] 这些都属于索引访问。

当交叉类型的成员类型之一具有数字索引签名或字符串索引签名时,结果类型就包含相应的数字索引签名或字符串索引签名,并且结果类型的索引签名值类型是各成员类型索引签名值类型的交叉类型,这也属于并集的概念。

这里 Clickable Focusable 都具有数字索引签名 ,结果类型 T 也具有数字索引签名,并且数字索引签名值类型是 number & string 类型,即 never 类型。

详解 TypeScript 中的交叉类型

这里只有 Clickable 具有数字索引签名,结果类型 T 也具有数字索引签名,该数字索引签名值类型是 number 类型。

详解 TypeScript 中的交叉类型

调用签名

调用签名的概念在[[联合类型]]中有讲到

JavaScript 中函数也是对象,调用签名的作用正是用来描述对象是否可以被当做函数来调用以及能被当做函数调用时参数、返回值的类型,其语法形式如下(与[[函数签名]]类似):

{
 (idnumber): string;
}
或者
interface T {
 (idnumber): string;
}

当交叉类型的任一成员类型具有调用签名时,结果类型也就包含相应调用签名,若多个成员类型都有调用签名,则结果类型会构成调用签名重载,这也属于并集的概念。

这里 Clickable 具有调用签名,因此结果类型 T 也具有该调用签名。

详解 TypeScript 中的交叉类型

这里 ClickableFocusable 分别具有调用签名,因此结果类型 T 具有调用签名重载。

详解 TypeScript 中的交叉类型

T 的调用签名重载等价于

interface S {
    (namestring): string
    (namenumber): number
}

签名重载的顺序与成员类型的顺序一致,type S = Focusable & Clickable 等价于

interface S {
 (namenumber): number
    (namestring): string
}

构造签名

构造签名的概念在[[联合类型]]中有讲到

调用签名类似,作用是描述对象是否可以作为构造函数被 new 运算符调用,其语法特性如下:

{
 new (id: number): string;
}
或者
interface T {
 new (id: number): string;
}

当交叉类型的成员类型任一成员类型具有构造签名时,结果类型就包含相应构造签名,若多个成员类型都有构造签名,则结果类型会构成构造签名重载,这也属于并集的概念。

这里 Clickable 具有构造签名,因此结果类型 T 也具有该构造签名。

详解 TypeScript 中的交叉类型

这里 ClickableFocusable 分别具有构造签名,因此结果类型 T 具有构造签名重载。

详解 TypeScript 中的交叉类型

T 的构造签名重载等价于

interface S {
    new (namestring): string
    new (name: number): number
}

签名重载的顺序与成员类型的顺序一致,type S = Focusable & Clickable 等价于

interface S {
 new (name: number): number
    new (namestring): string
}

属性修饰符

当交叉类型成员类型为带有只读可选等属性修饰符的[[空对象类型字面量]]或接口时,当所有成员类型具有属性修饰符,则联合类型的结果类型才具有相应的属性修饰符。

  • 只读 readonly 只有一个属性修饰符

详解 TypeScript 中的交叉类型

都具有属性修饰符

详解 TypeScript 中的交叉类型

只有一个成员类型具有属性及属性修饰符

详解 TypeScript 中的交叉类型

  • 可选 ? 只有一个属性修饰符

详解 TypeScript 中的交叉类型

都具有属性修饰符

详解 TypeScript 中的交叉类型

只有一个成员类型具有属性及属性修饰符

详解 TypeScript 中的交叉类型

交叉类型与联合类型

优先级

交叉类型符号 & 等价于数学运算符 x,联合类型符号 | 等价于数学运算符 +

A & B | C & D

等价于

(A & B) | (C & D)

可用数学表达式表示为

A x B + C x D

与函数类型字面量优先级

与函数类型字面量同时使用时,交叉类型和联合类型具有更高的优先级。

() => string | number

等价于

() => (string | number)
() => string | number & string

等价于

() => (string | (number & string))
简化为
() => (string | never)
简化为
() => string

分配率性质

交叉类型与联合类型组合使用时满足数学中的分配率规则。

(A | B) & (C | D)

等价于

(A & C) | (A & D) | (B & C) | (B & D)

可用数学表达式表示为

(A x C) + (A x D) + (B x C) + (B x D)

实例分析

使用上面的优先级和分配率性质分析以下例子。

type T = (string | 0) & (number | 'hello')

分析过程如下

type T = (string | 0) & (number | 'hello')
// 使用分配率展开
T = string & number | string & 'hello' | 0 & number | 0 & 'hello'
// 使用优先级分组
T = (string & number) | (string & 'hello') | (0 & number) | (0 & 'hello')
// 交叉类型简化:string & number 为 never,string & 'hello' 为 'hello',0 & 'hello' 为 never
T = never | 'hello' | 0 | never
// 联合类型简化/合并
T = never | 'hello' | 0
// 联合类型简化:never是尾端类型,是所有类型的子类型
T = 'hello' | 0 

一些疑难点

交叉类型的“并集”的理解?

交叉类型的定义说“交叉类型表示类型同时为多个类型”,这相当于是交叉类型是成员类型的交集,为何文中说“并集”?

解答如下:

文中所说的“并集”指的是,交叉类型的结果类型上的属性成员、函数签名、索引签名等的并集组成,是描述交叉类型的推导过程;“交叉类型是成员类型的交集”则指的是交叉类型的结果表现,两种说法都是对的。从交叉类型的结果表现来看,其结果类型确实是成员类型的交集。

交叉类型实际使用场景有哪些?

一般情况下使用交叉类型是没有意义的,那什么时候该使用交叉类型呢?

解答如下:

将多个接口类型合并成为一个类型是交叉类型的一个常见的使用场景。这样就能相当于实现了接口的继承,也就是所谓的合并接口类型。比如可以基于第三方库的接口类型来补充属性,从而创建适合自己需求的接口定义。

例如:vue3 中 VueConfiguration 定义如下,我们可以借助交叉类型来继承该定义并添加我们自己的属性、方法定义等。

export interface VueConfiguration {
  silent: boolean
  optionMergeStrategies: any
  devtools: boolean
  productionTip: boolean
  performance: boolean
  errorHandler(errError, vm: Vue, infostring): void
  warnHandler(msgstring, vm: Vue, tracestring): void
  ignoredElements: (string | RegExp)[]
  keyCodes: { [key: string]: number | number[] }
  async: boolean
}

添加 mode 属性定义

interface MyVueConfiguration = VueConfiguration & {
 mode: string
}