同事看完这几道题,发现 TS 交叉类型竟还没入门!

开发 前端
如果让你实现一个 RequiredByKeys 工具类型,用于把对象类型中指定的 keys 变成必填的,你知道怎么实现么?

你是不是已经掌握 TypeScript 的交叉类型了?如果是的话,你知道这些类型经过交叉运算后的结果么?如果不清楚的话,阅读完本文也许你就懂了。

// 非对象类型交叉运算
type N0 = string & number;
type N1 = any & 1;
type N2 = any & never;

// 对象类型交叉运算
type A = { kind: 'a', foo: string };
type B = { kind: 'b', foo: number };
type C = { kind: 'c', foo: number };

type AB = A & B;
type BC = B & C;

// 函数类型交叉运算
type F1 = (a: string, b: string) => void;
type F2 = (a: number, b: number) => void;

type Fn = F1 & F2

在学习 TypeScript 的过程中,你可以把类型理解成一系列值的集合。比如,你可以把数字类型看作是所有数字的集合,1.0、68 就属于这个集合中,而 "阿宝哥" 就不属于这个集合,因为它属于字符串类型。

同样,对于对象类型来说,我们也可以把它理解成对象的集合。比如以上代码中 Point 类型表示含有 x 和 y 属性,且属性值的类型都是 number 类型对象的集合。而 Named 类型表示含有 name 属性且属性值的类型是 string 类型对象的集合。

interface Point {
x: number;
y: number;
}

interface Named {
name: string;
}

在集合论中,假设 A,B 是两个集合,由所有属于集合 A 且属于集合 B 的元素所组成的集合,叫做集合 A 与集合 B 的交集。

当我们对 Point 类型和 Named 类型进行交集运算,就会产生新的类型。该类型中所包含的对象既属于 Point 类型,又属于 Named 类型。

在 TypeScript 中为我们提供了交叉运算符,来实现对多种类型进行交叉运算,所产生的新类型也被称为交叉类型。

下面我们来简单介绍一下交叉运算符,该运算符满足以下这些特性:

  • 唯一性:A & A 等价于 A
  • 满足交换律:A & B 等价于 B & A
  • 满足结合律:(A & B) & C 等价于 A & (B & C)
  • 父类型收敛:如果 B 是 A 的父类型,则 A & B 将被收敛成 A 类型
type A0 = 1 & number; // 1
type A1 = "1" & string; // "1"
type A2 = true & boolean; // true

type A3 = any & 1; // any
type A4 = any & boolean; // any
type A5 = any & never; // never

在以上代码中,any 类型和 never 类型比较特殊。除了 never 类型之外,任何类型与 any 类型进行交叉运算的结果都是 any 类型。

介绍完交叉运算符之后,我们来看一下对 Point 类型和 Named 类型进行交叉运算后,将产生什么样的类型?

interface Point {
x: number;
y: number;
}

interface Named {
name: string;
}

type NamedPoint = Point & Named
// {
//. x: number;
//. y: number;
//. name: string;
//. }

在以上代码中,新产生的 NamedPoint 类型将会同时包含 x、y 和 name 属性。但如果进行交叉运算的多个对象类型中,包含相同的属性但属性的类型不一致结果又会是怎样呢?

interface X {
c: string;
d: string;
}

interface Y {
c: number;
e: string
}

type XY = X & Y;
type YX = Y & X;

在以上代码中,接口 X 和接口 Y 都含有一个相同的 c 属性,但它们的类型不一致。对于这种情况,此时 XY 类型或 YX 类型中 c 属性的类型是不是可以是 string 或 number 类型呢?下面我们来验证一下:

let p: XY = { c: "c", d: "d", e: "e" }; // Error
let q: YX = { c: 6, d: "d", e: "e" }; // Error

为什么接口 X 和接口 Y 进行交叉运算后,c 属性的类型会变成 never 呢?这是因为运算后 c 属性的类型为 string & number,即 c 属性的类型既可以是 string 类型又可以是 number 类型。很明显这种类型是不存在的,所以运算后 c 属性的类型为 never 类型。

在前面示例中,刚好接口 X 和接口 Y 中 c 属性的类型都是基本数据类型。那么如果不同的对象类型中含有相同的属性,且属性类型是非基本数据类型的话,结果又会是怎样呢?我们来看个具体的例子:

interface D { d: boolean; }
interface E { e: string; }
interface F { f: number; }

interface A { x: D; }
interface B { x: E; }
interface C { x: F; }

type ABC = A & B & C;

let abc: ABC = { // Ok
x: {
d: true,
e: '阿宝哥',
f: 666
}
};

由以上结果可知,在对多个类型进行交叉运算时,若存在相同的属性且属性类型是对象类型,那么属性会按照对应的规则进行合并。

但需要注意的是,在对对象类型进行交叉运算的时候,如果对象中相同的属性被认为是可辨识的属性,即属性的类型是字面量类型或字面量类型组成的联合类型,那么最终的运算结果将是 never 类型:

type A = { kind: 'a', foo: string };
type B = { kind: 'b', foo: number };
type C = { kind: 'c', foo: number };

type AB = A & B; // never
type BC = B & C; // never

在以上代码中,A、B、C 三种对象类型都含有 kind 属性且属性的类型都是字符串字面量类型,所以 AB 类型和 BC 类型最终都是 never 类型。接下来,我们来继续看个例子:

type Foo = {
name: string,
age: number
}

type Bar = {
name: number,
age: number
}

type Baz = Foo & Bar
// {
//. name: never;
//. age: number;
// }

在以上代码中,Baz 类型是含有 name 属性和 age 属性的对象类型,其中 name 属性的类型是 never 类型,而 age 属性的类型是 number 类型。

但如果把 Foo 类型中 name 属性的类型改成 boolean 类型的话,Baz 类型将会变成 never 类型。这是因为 boolean 类型可以理解成由 true 和 false 字面量类型组成的联合类型。

type Foo = {
name: boolean, // true | false
age: number
}

type Bar = {
name: number,
age: number
}

type Baz = Foo & Bar // never

其实除了对象类型可以进行交叉运算外,函数类型也可以进行交叉运算:

type F1 = (a: string, b: string) => void;  
type F2 = (a: number, b: number) => void;

let f: F1 & F2 = (a: string | number, b: string | number) => { };
f("hello", "world"); // Ok
f(1, 2); // Ok
f(1, "test"); // Error

对于以上代码中的函数调用语句,只有 f(1, "test") 的调用语句会出现错误,其对应的错误信息如下:

没有与此调用匹配的重载。
1 个重载(2),“(a: string, b: string): void”,出现以下错误。
类型“number”的参数不能赋给类型“string”的参数。
2 个重载(2),“(a: number, b: number): void”,出现以下错误。
类型“string”的参数不能赋给类型“number”的参数。ts(2769)

根据以上的错误信息,我们可以了解到 TypeScript 编译器会利用函数重载的特性来实现不同函数类型的交叉运算,要解决上述问题,我们可以在定义一个新的函数类型 F3,具体如下:

type F1 = (a: string, b: string) => void;  
type F2 = (a: number, b: number) => void;
type F3 = (a: number, b: string) => void;

let f: F1 & F2 & F3 = (a: string | number, b: string | number) => { };
f("hello", "world"); // Ok
f(1, 2); // Ok
f(1, "test"); // Ok

掌握了交叉类型之后,在结合往期文章中介绍的映射类型,我们就可以根据工作需要实现一些自定义工具类型了。比如实现一个 PartialByKeys 工具类型,用于把对象类型中指定的 keys 变成可选的。

type User = {
id: number;
name: string;
age: number;
}

type PartialByKeys<T, K extends keyof T> = Simplify<{
[P in K]?: T[P]
} & Pick<T, Exclude<keyof T,K>>>

type U1 = PartialByKeys<User, "id">
type U2 = PartialByKeys<User, "id" | "name">

那么如果让你实现一个 RequiredByKeys 工具类型,用于把对象类型中指定的 keys 变成必填的,你知道怎么实现么?知道答案的话,你喜欢以这种形式学 TS 么?

责任编辑:武晓燕 来源: 全栈修仙之路
相关推荐

2022-05-07 07:33:55

TypeScript条件类型

2022-04-29 06:54:48

TS 映射类型User 类型

2016-12-16 12:32:50

阿里数据分析职业要求

2020-01-18 07:55:28

JavaScript开发

2020-01-13 07:50:58

JavaScript开发

2022-08-29 10:01:59

Vue验证机制

2019-07-18 15:42:53

Redisoffer数据库

2021-05-28 10:09:22

GC详解Java JVM

2021-07-08 15:34:35

谷歌AI人工智能

2022-05-09 09:07:47

JavaScript框架开发

2018-06-21 07:28:50

2021-07-03 10:59:09

黑客勒索软件攻击

2015-10-29 14:24:42

JavaScript基础知识

2022-01-19 23:41:56

TS索引类型

2017-09-23 15:17:21

散热笔记本电脑蓝屏

2020-10-21 09:19:27

Flutter开源项目

2009-06-01 13:37:47

jpa技术总结ejb

2021-09-14 07:47:16

支付宝架构监控

2022-12-30 08:08:30

2022-06-08 08:01:28

模板字面量类型
点赞
收藏

51CTO技术栈公众号