深入理解 TypeScript 的类型系统

2021/03/06

把类型看作值的集合

把 TypeScript 中的类型看作一组可能的值的集合,这有助于你更好的理解 TypeScript 中的类型和类型操作。

例如,你可以把 number 类型看成是所有数值的集合。42-37 都在其中,但是 'hello' 不在其中。根据是否设置 strictNullChecksnullundefined 可能是也可能不是这个集合的一部分:

let num: number = 42 // ok
num = -37 // ok
num = 'hello' // ~ Type string is not assignable to type number

最小的集合是空集,它对应于 TypeScript 中的 never 类型。因为空集不包含任何值,所以任何值都不能赋值给一个具有 never 类型的变量:

const x: never = 12 // ~ Type '12' is not assignable to type 'never'

其次最小的集合就是只包含一个值的集合,这对应于 TypeScript 中的字面类型(literal type),例如:

type A = 'A'
type B = 'B'
type Three = 3

可以将多个集合求并集生成一个新的集合,这对应于 TypeScript 中的联合类型,类型操作 | 可以理解为集合求并集操作:

type AB = 'A' | 'B'
type AB3 = 'A' | 'B' | 3

类型 B 能赋给(assignable to)类型 A,当且仅当类型 B 代表的集合是类型 A 代表的集合的子集(subset),实际上,这正是 TypeScript 的类型检查的本质:

const ab: AB = Math.random() > 0.5 ? 'A' : 'B' // OK, {"A", "B"} 是 {"A", "B"} 的子集
const ab3: AB3 = ab // OK, {"A", "B"} 是 {"A", "B", 3} 的子集

declare let three: AB3
const back: AB = three // Error, {"A", "B", 3} 不是 {"A", "B"} 的子集

上面这些例子都很容易理解,因为这些类型代表的集合都是有限集,但是在实际场景中我们用到的类型很多是无限集,要理解这些类型和类型操作,就不那么容易。例如:

interface Identified {
  id: string
}

这个 interface 定义的集合就是一个无限集,它描述的是集合里的每个元素的形状,即必须包含一个 id 属性,且这个属性的值的类型必须是 string 类型的子集。也就是说,只要一个对象满足这一点,它就属于这个集合,即使它还包含额外的属性。

理解了这一点可以帮助你更好的理解在这些类型上的类型操作:

interface Person {
  name: string
}
interface Lifespan {
  birth: Date
  death?: Date
}
type PersonSpan = Person & Lifespan

& 操作符用来计算两个类型的交集,那么哪些值是属于 PersonSpan 类型呢?咋一看,PersonLifespan 没有相同的属性,所以它们的交集应该是空集。是这样吗?

答案是否定的,首先,我们要弄清楚类型操作是作用在值的集合上,而不是类型定义的属性上,其次,一个包含额外属性(类型定义里声明的属性之外的属性)的值同样属于这个类型的集合。所以在上面的例子中,如果一个值同时包含 PersonLifespan 的属性,那么它就属于 PersonSpan 类型:

const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date('1912/06/23'),
  death: new Date('1954/06/07')
} // Ok

一般的规则是,多个 interface 的交叉类型(intersection type)中的值所包含的属性,是这每个组成的 interface 中的属性的联合(union)。可以用下面的公式来表达:

keyof (A & B) = (keyof A) | (keyof B) // (1)

这很容易理解,也是符合直觉的。但是对于多个 interface 的联合类型(union type)来说,就有点绕了:

type K = keyof (Person | Lifespan) // type K = never

为什么是 never?前面我们说过联合操作 | 相当于求并集,Person | Lifespan 所代表的集合由所有属于 Person 的值和所有属于 Lifespan 值组成,例如:

const p1 = { name: 'Rose' }
const p2 = { name: 'Monica', age: 23 }
const l1 = {
 birth: new Date('1932/06/23'),
 death: new Date('1992/03/21')
}
const l2 = {
 birth: new Date('1912/06/23'),
 death: new Date('1952/03/21'),
 gender: 'male'
}

let p: Person | Lifespan = p1 // Ok
p = p2 // Ok
p = l1 // Ok
p = l2 // Ok

可以看到 p1p2l1l2 都属于 Person | Lifespan 类型,但是由于 PersonLifespan 的接口定义中所描述的属性没有重叠的部分,所以 TypeScript 无法找到某一个属性是 Person | Lifespan 这个集合中的每一个值都包含的,例如上面的 p1p2l1l2 之间没有共同的属性,但是他们都属于 Person | Lifespan,所以 TypeScript 推断出 keyof (Person | Lifespan) 是空集,即 never

更通用的规则,也可以用下面的公式来表达:

keyof (A | B) = (keyof A) & (keyof B) // (2)

如果你能对 (1),(2) 这两个公式建立直觉,那么你就朝着深入理解 TypeScript 的类型系统前进了一大步。

另一个表达 PersonSpan 类型的方法是使用 extends

interface Person {
  name: string
}
interface PersonSpan extends Person {
  birth: Date
  death?: Date
}

如果把类型看作值的集合,那这里的 extends 该如何理解?很简单,A extends B,可以直接理解为 AB 的子集(subset)。PersonSpan 中的值必须包含 name 属性且它的值是个 string,同时也必须包含 birth 属性。所以 PersonSpan 是一个符合要求的子集。你可能还听过子类型(subtype)的说法,它和这里子集(subset)表达的意思相同。看下面的例子:

interface Vector1D { x: number }
interface Vector2D extends Vector1D { y: number }
interface Vector3D extends Vector2D { z: number }

这里你可以说 Vector3DVector2D 的子类型,Vector2D 又是 Vector1D 的子类型,从集合的角度,可以用韦恩图形象的描述它们之间的关系:

我们上面都是针对对象类型使用集合的概念进行解释,如果是字面类型(literal type)以及它们的联合类型,采用集合的解释会更加直观。extends 也可做为泛型的约束出现,在这种场景下,它也是子集(subset)的意思:

function getKey<K extends string>(val: any, key: K) {
  // ...
}

这里的 extends string 是什么意思?如果你从对象继承的角度来思考,那就很难解释,而如果从集合的角度来考虑,就很清楚了:K 代表的集合必须是 string 的子集。这包括字符串字面类型(string literal type)、字符串字面类型的联合类型(union of string literal types)和 string 本身。

getKey({}, 'x') // OK, 'x' extends string
getKey({}, Math.random() < 0.5 ? 'a' : 'b') // OK, 'a'|'b' extends string
getKey({}, document.title); // OK, string extends string
getKey({}, 12)
 // ~~ Type '12' is not assignable to parameter of type 'string'

如果把类型看作值的集合,这意味着具有相同值集合的两个类型是相同的。实际上就是这样,除非两个类型在语义上是不同的,只是碰巧有相同的值集合,否则没有理由将同一个类型定义两次。

最后,值得注意的是,并非所有的值集都有对应的 TypeScript 类型。所有整数组成的集合就没有对应的 TypeScript 类型,所有只包含 x 和 y 属性的对象组成的集合,也找不到对应的 TypeScript 类型。

TypeScript 中的术语与集合论中的术语的有如下对应关系:

TypeScript termSet term
never∅ (empty set)
Literal typeSingle element set
Value assignable to TValue ∈ T (member of)
T1 assignable to T2T1 ⊆ T2 (subset of)
T1 extends T2T1 ⊆ T2 (subset of)
T1 | T2T1 ∪ T2 (union)
T1 & T2T1 ∩ T2 (intersection)
unknownUniversal set

总结

  • 把类型看作是类型的集合,这些集合可以是有限集(boolean,literal types)或者是无限集(number,string)。
  • 请记住,一个对象仍然可以属于一个类型,即使它有在类型声明中没有提到的额外属性。
  • 理解类型操作是作用在类型代表的集合上的。
  • 把 ”extends“,“assignable to”,“subtype of” 看作是 ”subset of“(子集)的同义词。