【Typescript】进击的基础(一)交叉类型和联合类型-集合论角度理解

4,547 阅读7分钟

引言

  1. 是否思考过交叉类型中“交叉”一词究竟代表什么?为什么叫交叉类型?
  2. 参考以下示例:
interface A {
  x: number;
  y: number;
}
interface B {
  y: number;
  z: number;
}
type U = A & B;
type I = A | B;

具体使用属性时,会发现ts的提示中,交叉类型 U 具有 x,y,z 三个属性,而联合类型 I 仅仅只有 y 属性,这个“联合”体现在哪里?

  1. 本文尝试从集合角度(个人思考),理解其设计的原则和模式

正文

类型和集合

这里需要重拾一下什么是“类型”。抛开“实例化”这些术语,从集合角度出发,类型应该是指 具有特定特性的若干值的集合

如在Typescript中,number类型是所有数字的集合,string类型是所有字符串的集合,字面量类型 1 是只 包含一个元素 1 的集合,字面量类型 "1" 是只包含一个元素 "1" 的集合。

但是在对象中,集合的概念就非常容易混淆了,我以前一直不能理解为什么说 子类是父类的子集 ,明明子类会比父类拥有更多的属性和方法,难道不是父类是子类的子集?

这里是我一直以来都陷入的误区。看下面例子:

interface A {
  x: number;
}
interface B extends A {
  y: number;
}
const obj = {
  x: 1,
  xx: 2,
  xxx: 3,
};
const a: A = obj;
const b: B = obj; // error

显然,变量 aA 类型,但是实际上它所代表的对象,具有 x,xx,xxx 三个属性。显然,这里可以得出一个重要观点:对象类型只要求它所描述的对象 这些属性,而不一定是 有且仅有 这些属性。

在这个基础上,不难理解类型 B 是类型 A 的子集,因为 A 是所有具有 x 属性的对象的集合(有就行,对象可能有其他任意属性),而 B 除了要求对象有 x 属性,还要有一个 y 属性,要求明显比 A 更严格,套入集合的观点,明显 BA 的子集。

对象类型是若干对象的集合,而不是属性的集合。只要一个对象具有它所描述的全部属性,那么该对象就是该类型的一员

:Typescript并不完全基于集合的数学定义。为了实际的类型安全,如果改成用字面量对象赋值,将会报错

const a: A = {
  x: 1,
  xx: 2, // ERROR
  xxx: 3, // ERROR
};

交集类型和并集类型运算符的简单运算

交叉类型即 Intersection types ,从当前文章角度,翻译它为 交集类型 可能会更加合适。

联合类型即 Union types,从当前文章角度,翻译它为 并集类型 可能会更加合适。

从集合角度,&,| 运算符的各种运算结果:

type A = 1 | 2 | 3;
type B = 2 | 3 | 4;
type C = A & B; // C为: 2 | 3
type D = A & number; // D为: 1 | 2 | 3
type E = A | number; // E为: number
type F = number & string; // F为: never

类型 A-E 套一下集合运算即可。至于类型 F , 虽然官方对 never 的讨论定义只是 “不会出现的类型” ,但我觉得其实是 “空集” 的思想在潜移默化的影响他们,把 never 看作是空集就完事了。

至于官方给到的几个运算特性,估计也是集合的思想在影响着,这些运算特性是显然易见的:

对于交集运算符 &

  1. 唯一性: A & A 等价于 A.
  2. 满足交换律: A & B 等价于 B & A (函数调用和构造函数有些许不同,下面会讲).
  3. 满足结合律: (A & B) & C 等价于 A & (B & C).
  4. 父类型收敛: 当且仅当 BA 的父类型时,A & B 等价于 A.

对于并集运算符 |

  1. 唯一性: A | A 等价于 A.
  2. 满足交换律: A | B 等价于 B | A.
  3. 满足结合律: (A | B) | C 等价于 A | (B | C).
  4. 子类型收敛: 当且仅当 BA 的子类型时,A | B 等价于 A.

:Typescript的用词是 Supertype 和 Subtype,而不是 Superclass 和 Subclass 。也就是 AB 并不一定是 Class 也可能是typeinterface,相信你能理解这些名词的差异。

交集类型和并集类型运算符的高级运算

Intersection types 交集类型:

  • &| 有更高的运算优先级
  • 如果类型 A 的属性 p 是类型 X ,类型 B 的属性 p 是类型 Y ,那么交集类型 A & B 中对应的属性 pX & Y
  • 交集类型 A & B 具有类型 AB 的所有属性(即 属性的并集 )。再次强调,交集类型 不是属性集合的交集,而是 对象集合的交集。(类型的属性越多,代表限制条件越多,对象只有满足这些条件的并集,才能归为该交集类型)
  • 如果 AB 是函数类型,那么 A & B 代表重载函数,此时集合运算顺序即为重载函数的函数签名顺序(如vscode智能提示中,第一个提示的重载函数类型会是 A 类型)
  • 如果 A 是对象类型,B 是原始数据类型(string,number 这些),代表“特殊tag”的原始数据类型

最后两点可能要结合实际代码理解:

  1. 函数的交集类型是重载函数:
type M = (a: number) => number;
type N = (a: string) => string;

function overload(a: number): number;
function overload(a: string): string;
function overload(a: number | string) {
  return a;
}

let m: M = overload; // OK
let n: N = overload; // OK
let mn: M & N = overload; // OK

显然类型 M 描述的函数是:能够 接受一个 number 并返回 number。注意是 能够,而不是 能且只能,和前面的对象类型有异曲同工之妙。即overload 满足类型 M 是集合 M 的一员,也满足类型 N 是集合 N 的一员,所以它必然是 M & N 这个交集中的元素。

因此最终表现形式会是:函数类型的交集类型是重载函数

  1. 虽然对象类型和原始数据类型的交集,按集合逻辑应该是空集,但在ts中最终表现为 装箱 后与对象类型的属性并集。这实际上完全是为了另一个目的:nominal,即使得“类型别名”(type)具有唯一性(或者叫tag),官方FAQ有提及
// Strings here are arbitrary, but must be distinct
type SomeUrl = string & {'this is a url': {}};
type FirstName = string & {'person name': {}};

// Add type assertions
let x = <SomeUrl>'';
let y = <FirstName>'bob';
x = y; // Error

// OK
let xs: string = x;
let ys: string = y;
xs = ys;

这在某些场景下有意义:A函数实际接受一个字符串,但只想接受B函数处理过的字符串作为参数。那么可以利用此特性实现:

function B(str:string){
  return str as string & {__SPEC__: 'SPEC'}
}
function A(str: string & {__SPEC__: 'SPEC'}){...}

Union types 并集类型:

  • 如果类型 A 的属性 p 是类型 X ,类型 B 的属性 p 是类型 Y ,那么并集类型 A | B 中对应的属性 pX | Y
  • 并集类型 A | B 一定具有 类型 AB 的重复属性(即 属性的交集 )。这里也需要从实际出发,从类型安全角度,在不知道交集中的某个元素具体属于 A 还是 B 的情况下,访问时 TS只能给你提示它们一定都有的属性,因此表现上会是交集;赋值 时不多说了,满足AB就行,标准的 并集 概念。
  • 函数类型同样表现为并集的概念,不需要特别理解。唯一值得注意的地方是,在TS确实无法推测函数类型的情况下,调用这个函数:逆变 位置(参数)取交集类型,协变 位置(返回值)取并集类型。

协变与逆变

后记

交叉类型的讨论:github.com/microsoft/T…

交叉类型的PR:github.com/microsoft/T…

联合类型的讨论:github.com/microsoft/T…

联合类型的PR:github.com/microsoft/T…

关于nominal的讨论: github.com/microsoft/T…

🤔还有个坑没填,所以这个“交叉”是...?
😂毕竟这里是TS,不是数学领域,当术语吧