pexels-photo-11950172.jpeg

用了 TypeScript 一段时间后,自我感觉对 TypeScript 工具类型有一定理解了,随手就能噼里啪啦出一大堆 PartialOmit 啥的。但就是吧,有时会遇到一些复杂的业务场景(或需要对第三方库的类型做进一步转换时),会突然的卡住。卡住?没错,就是那种突然不知代码该怎么往下写的感觉,然后陷入了自我怀疑…
image.png
这种感觉,可能也就伍佰老师能懂吧 … 😏

在这篇文章中,我们从官方文档着手并结合网上的一些文章以及其源码实现,重新理解并梳理一下 TypeScript 中「工具类型」这块内容。

一、什么是「工具类型」?

试想一个场景,你正在写一个 “统计社区居民信息” 的需求,业务要求所有的居民都必须填写 “姓名”、”性别” 和 “职业”,但 “年龄” 可选。于是,你这样设计了 “居民信息组件” 的属性类型:

  1. interface ResidentProps {
  2. name: string
  3. gender: string
  4. work: string
  5. age?: number
  6. }

临下班是,你发现得写个 “男性居民信息组件”,于是这样定义了它的属性类型:

  1. interface GentlemenProps {
  2. name: string
  3. work: string
  4. age?: number
  5. }

第二天,你发现还有个 “(填写了年龄的)男性居民信息组件” 要写,于是又多了这样一个属性类型:

  1. interface GentlemenWithAgeProps {
  2. name: string
  3. work: string
  4. age: number
  5. }

回头看了两眼好像不太对劲,这三个类型长得都差不多,这样反反复复定义来定义去也太傻 x 了吧?而且,当我觉得一开始定义的 work 字段用来表示 “职业” 不太准确,想改成 profession 时还得一个个去改。这不是好鸡肋吗?就没有一些工具能辅助我 “快速完成类型定义” 且 “规避掉上述提到的「一个个去改」的问题” 吗?

我太难了 … 😔

骚年淡定,这个可以有,你打开 官方文档(utility-types )部分 就会看到这样一句话:

TypeScript provides several utility types to facilitate common type transformations. These utilities are available globally.

通俗点说就是:TypeScript 提供了一系列的「工具类型」来使得 “常见的类型转换” 变得更加容易,且这些「工具类型」是全局可用的,不需要 import 就能直接用的。

照猫画虎(用了「工具类型」)后,发现上面的类型定义简化了好多:

  1. interface ResidentProps {
  2. name: string
  3. gender: string
  4. work: string
  5. age?: number
  6. }
  7. // 仅省去 ResidentProps 中的 gender
  8. type GentlemenProps = Omit<ResidentProps, 'gender'>
  9. // 将 GentlemenProps 中所有的属性都变成必填的
  10. type GentlemenWithAgeProps = Required<GentlemenProps>

而且,当我想把 work 属性改成 profession 时,真正要去做的也就是像下面这样调整下 ResidentProps 类型的代码就好了。

在「工具类型」作用下,GentlemenPropsGentlemenWithAgeProps 会自动跟着调整 “职业” 的类型声明。

  1. interface ResidentProps {
  2. name: string
  3. gender: string
  4. - work: string
  5. + gender: string
  6. age?: number
  7. }

image.png

实际上,TypeScript 提供的「工具类型」远不止上面提到的 OmitRequired,下面我们将会一一解析。

二、 「工具类型」详解

1、Partial<T>

用于构造一个新的类型,它有着给定类型 T 的所有属性,且每个属性都被设置成了 “可选“。举个栗子:

  1. interface Todo {
  2. title: string
  3. desc: string
  4. }
  5. type Todo2 = Partial<Todo>
  6. // 上面的 Todo2 等效于下面的定义
  7. type Todo2 = {
  8. title?: string
  9. desc?: string
  10. }

2、Require<T>

用于构造一个新的类型,它有着给定类型 T 的所有属性,且每个属性都被设置成了 “必选“。举个栗子:

  1. interface Todo {
  2. title?: string
  3. desc?: string
  4. }
  5. type Todo2 = Required<Todo>
  6. // 上面的 Todo2 等效于下面的定义
  7. type Todo2 = {
  8. title: string
  9. desc: string
  10. }

可以看出,PartialRequired 其实是 “对立” 的一组「工具类型」,前者设属性 “可选“,后者设属性 “必选“ 。

3、Readonly<T>

用于构造一个新的类型,它有着给定类型 T 的所有属性,且每个属性都被设置成了 “只读“。举个栗子:

  1. interface Todo {
  2. title: string
  3. desc: string
  4. }
  5. type Todo2 = Readonly<Todo>
  6. // 上面的 Todo2 等效于下面的定义
  7. type Todo2 = {
  8. readonly title: string
  9. readonly desc: string
  10. }

这意味当你想要修改 Todo2 对象实例的任何属性时,会有语法报错:

  1. const todo: Todo = { title: '吃苹果', desc: '今天中午要吃一个苹果' }
  2. // ✅ 符合类型定义逻辑
  3. todo.title = '吃苹果啦'
  4. const todo2: Todo2 = { title: '吃苹果', desc: '今天中午要吃一个苹果' }
  5. // ❌ 不符合类型定义逻辑:
  6. // Cannot assign to 'title' because it is a read-only property.
  7. todo2.title = '吃苹果啦'

所以在日常开发中,当你暴露的方法给外部调用但又不希望该方法返回结果被他人修改时,可以显式地给返回值声明为 “只读“:

  1. function getSomethingFreezed<T>(obj: T): Readonly<T>

4、Record<K, T>

构造一个新的对象类型(object type):

  • 它的「键」都是联合类型 K 的成员。
  • 它的「值」的类型都是 T

通常被用来将一个类型的 properties 映射成另一个类型,举个栗子:

  1. interface Todo {
  2. title: string
  3. desc: string
  4. }
  5. type Order = 'first' | 'second' | 'third'
  6. type Todos = Record<Order, Todo>
  7. // 上面的 Todos 等效于下面的定义
  8. type Todos = {
  9. first: Todo
  10. second: Todo
  11. third: Todo
  12. }

5、Pick<T, K>

构造一个新的对象类型(object type):

  • 它的属性集合是 T 的属性集合的子集。
  • 具体节选哪些属性则由 K 来指定。

    K 可以是「单个字符串字面量(string literal)」,也可以是「联合字符串字面量(union of string literals)」。

举俩栗子:

  1. // Example 1
  2. interface Todo {
  3. title: string
  4. desc: string
  5. completed: boolean
  6. }
  7. type Todo2 = Pick<Todo, 'title'>
  8. // 上面的 Todo2 等效于下面的定义
  9. interface Todo2 {
  10. title: string
  11. }
  12. // Example 2
  13. interface Todo {
  14. title: string
  15. desc: string
  16. completed: boolean
  17. }
  18. type Todo2 = Pick<Todo, 'title' | 'completed'>
  19. // 上面的 Todo2 等效于下面的定义
  20. interface Todo2 {
  21. title: string
  22. completed: boolean
  23. }

6、Omit<T, K>

构造一个新的对象类型(object type):

  • 它的属性集合是 T 的属性集合 “忽略了部分属性后” 的子集。
  • 具体忽略哪些属性则由 K 来指定。

    Pick,其中 K 可以是 「单个字符串字面量(string literal)」,也可以是「联合字符串字面量(union of string literals)」,下文有类似用法的话不再赘述。

还是俩栗子:

  1. // Example 1
  2. interface Todo {
  3. title: string
  4. desc: string
  5. completed: boolean
  6. }
  7. type Todo2 = Omit<Todo, 'desc' | 'completed'>
  8. // 上面的 Todo2 等效于下面的定义
  9. interface Todo2 {
  10. title: string
  11. }
  12. // Example 2
  13. interface Todo {
  14. title: string
  15. desc: string
  16. completed: boolean
  17. }
  18. type Todo2 = Omit<Todo, 'desc'>
  19. // 上面的 Todo2 等效于下面的定义
  20. interface Todo2 {
  21. title: string
  22. completed: boolean
  23. }

可以看到,上面示例中的 Omit<Todo, 'desc'> 和前面示例中的 Pick<Todo, 'title' | 'completed'> 的结果是一致的。但啥时候用 Omit 啥时候用 Pick ,得看使用场景和看个人爱好了。

7、Exclude

构造一个新的类型:

  • 它是由 “联合类型 T 的成员” 中排除 “在联合类型 U 的相同成员” 后的剩余成员联合起来的一个新类型。

有点抽象?没关系,吃颗个栗子就能理解了:

  1. // 「周一到周日」
  2. type Days = 'Mon.' | 'Tues.' | 'Wed.' | 'Thurs.' | 'Fri.' | 'Sat.' | 'Sun.'
  3. // 「周六、周日」
  4. type Weekend = 'Sat.' | 'Sun.'
  5. // 从 "Days 成员" 中移除在 "Weekend 中存在的成员" 后,剩余的成员就是「周一到周五」啦
  6. type Workday = Exclude<Days, Weekend>
  7. // 上面的 Workday 等效于下面的定义
  8. type Workday = 'Mon.' | 'Tues.' | 'Wed.' | 'Thurs.' | 'Fri.'

稍微能理解了吧?那我来问两个问题:

  • Q1:如果 UT 大,构造出来的类型是啥?
  • Q2:如果 U 中有成员不在 T 中,构造出来的类型又是啥?

答案是:

上面的两个答案用一句话概括也行:

  • 排除时仅排除 UT 中的成员,所有成员都排除完了得到 never 类型,否则得到 “剩余成员联合起来的类型” 。

举些栗子来看下:

  1. // Example 1
  2. type T1 = 'A' | 'B'
  3. type T2 = 'A' | 'B'
  4. type T3 = Exclude<T1, T2> // = never
  5. // Example 2
  6. type T1 = 'A' | 'B'
  7. type T2 = 'A' | 'B' | 'C'
  8. type T3 = Exclude<T1, T2> // = never
  9. // Example 3
  10. type T1 = 'A' | 'B'
  11. type T2 = 'A' | 'C'
  12. type T3 = Exclude<T1, T2> // = 'B'
  13. // Example 4
  14. type T1 = 'A' | 'B'
  15. type T2 = 'C'
  16. type T3 = Exclude<T1, T2> // = 'A' | 'B'

实际上,Exclude<T, U> 不仅可用于排除「字符串字面量联合类型」,也可用于排除「非字符串字面量联合类型」,再举个栗子:

  1. // Example 5
  2. type T1 = number | string | (() => void)
  3. type T2 = () => void
  4. type T3 = Exclude<T1, T2> // = number | string

从 T3 的结果来看,我们已经去掉了 T1 中的 () => void ,只留下了 numberstring

8、Extract<T, U>

构造一个新的类型:

  • 它是从 “联合类型 T 的成员” 中提取出 “在联合类型 U 的相同成员” 后联合起来的一个新类型。

    前面提到的 Exclude<T, U> 与该「工具类型」要达到的目的其实一致的,只是两者去罗马的路不一样而已:前者 “排除“,后者 “提取“。

免闲谈,上栗子:

  1. // Example 1
  2. type T1 = 'A' | 'B' | 'C'
  3. type T2 = 'A' | 'C'
  4. type T3 = Extract<T1, T2> // = 'A' | 'C'
  5. // Example 2
  6. type T1 = 'A' | 'B' | 'C'
  7. type T2 = 'A' | 'B' | 'C' | 'D'
  8. type T3 = Extract<T1, T2> // = 'A' | 'B' | 'C'
  9. // Example 3
  10. type T1 = 'A' | 'B' | 'C'
  11. type T2 = 'A' | 'D'
  12. type T3 = Extract<T1, T2> // = 'A'
  13. // Example 4
  14. type T1 = 'A' | 'B' | 'C'
  15. type T2 = 'D'
  16. type T3 = Extract<T1, T2> // = never
  17. // 一句话概括:
  18. // 根据 `U` 来提取 `T` 中有的,没有的不提,提不到就 `never`

9、NonNullable<T>

构造一个 新的T 的非空类型:

  • 它是把 Tnullundefind 成员都排除掉后得到的类型。

举俩栗子:

  1. // Example 1
  2. type T1 = string | number | null | undefined
  3. type T2 = NonNullable<T1> // = string | number
  4. // Example 2
  5. type T1 = string[] | null | undefined
  6. type T2 = NonNullable<T1> // = string[]

10、Parameters

构造一个新的类型:

  • 它是从函数类型 T 的参数列表中依次获取参数类型后得到的一个元组类型。

举仨栗子:

  1. // Example 1
  2. type F = () => void
  3. type T = Parameters<F> // = []
  4. const t1: T = [] // ✅ Type correct
  5. const t2: T = ['a'] // ❌ Type '[string]' is not assignable to type '[]'
  6. // Example 2
  7. type F = (a: string) => void
  8. type T = Parameters<F> // = [a: string]
  9. const t1: T = ['hello'] // ✅ Type correct
  10. const t2: T = [123] // ❌ Type 'number' is not assignable to type 'string'
  11. // Example 3
  12. type F = (a: string, b: number) => void
  13. type T = Parameters<F> // = [a: string, b: number]
  14. const t1: T = ['hello', 123] // ✅ Type correct
  15. const t2: T = ['hello', 'world'] // ❌ Type 'string' is not assignable to type 'number'

看起来挺厉害的就是不知道啥场景会用到,举一个?好勒:

  1. // 假设第三方库提供了一个函数
  2. export function repeatSomething(
  3. target: string | null | undefined,
  4. count: number)
  5. {
  6. return target ? target.repeat(count) : target
  7. }
  1. // 因为某种原因,你得在 repeatSomething 基础上封装一个函数给身边的小伙伴用
  2. import { repeatSomething } from 'an-awesome-lib'
  3. // 1️⃣ 抽离出 repeatSomething 函数参数的元组类型
  4. type RepeatParams = Parameters<typeof repeatSomething>
  5. // 2️⃣ 在 myFuncion 参数中直接使用该类型来标注参数
  6. // 这样在调用 repeatSomething 时就可以通过「解构数组」来传参啦
  7. export function myFuncion(myFlag: boolean, repeatParams: RepeatParams) {
  8. const repeated = repeatSomething(...repeatParams)
  9. if (myFlag) {
  10. // do something with `repeated` ...
  11. } else {
  12. // do something else with `repeated` ...
  13. }
  14. }

11、ReturnType<T>

构造一个新的类型:

  • 它等同于函数类型 T 的返回值的类型。

举些栗子:

  1. // Example 1
  2. type T1 = () => void
  3. type T2 = ReturnType<T1> // = void
  4. // Example 2
  5. type T1 = (a: string) => void
  6. type T2 = ReturnType<T1> // = void
  7. // Example 3
  8. type T1 = () => string
  9. type T2 = ReturnType<T1> // = string
  10. // Example 4
  11. type Todo = {
  12. title: string
  13. desc: string
  14. }
  15. type T1 = () => Todo
  16. type T2 = ReturnType<T1> // = Todo
  17. // Example 5
  18. type T1 = <T extends number[]>(a: T) => T
  19. type T2 = ReturnType<T1> // = number[]

TypeScript 中还有一些关于 classthis 的「工具类型」,比如 ConstructorParametersInstanceTypeThisParameterTypeOmitThisParameterThisType。 因为本人日常工作中很少会用,这里暂且跳过,感兴趣的朋友可以自己上去看下。

三、「工具类型」源码分析

所谓顾其名知其意,「工具类型」中的 “工具” 表示它实际上是 TypeScript 封装好的的一些工具而已,在形式跟我们日常开发中在 utils.ts 中定义的方法是差不多的。区别在于这些「工具类型」是全局可用、开箱即用的。

既然这样,如果理解了这些「工具类型」的源码实现,是不是有助于我们在工作中更加灵活的使用 TypeScript 的类型。

1、Partial<T>

  1. /**
  2. * Make all properties in T optional
  3. */
  4. type Partial<T> = {
  5. [P in keyof T]?: T[P];
  6. };

我们知道,在声明和使用对象类型时,姿势大致是这样的:

  1. // 该对象类型所有的 `key` 都是 string,所有的 `value` 都是 number
  2. type Obj = {
  3. [key: string]: number
  4. }
  5. // 声明一个 Obj 的实例
  6. const obj: Obj = {
  7. age: 18, // ✅ Type correct
  8. name: 'zhangsan', // ❌ Type 'string' is not assignable to type 'number'
  9. }

那么,上面源码中的 [P in keyof T]?: T[P] 是个啥意思呢?

以前面的 Todo 为例,然后拆解来看:

  1. // 1️⃣ `keyof T` 作用是把 T 的 key 都拿出来,形成一个 "字符串字面量联合类型"
  2. type Todo = {
  3. title: string
  4. desc: string
  5. }
  6. type T1 = keyof Todo
  7. // 所以上面的 keyof 其实等价于下面
  8. type T1 = "title" | "desc"
  9. // 2️⃣ `P in U` 则限定了 P 必须是联合类型的成员之一,所以
  10. type T1Obj = {
  11. [P in T1]: string
  12. }
  13. const t1Obj: T1Obj = {
  14. title: '吃苹果', // ✅ Type correct
  15. desc: '今天中午吃一个苹果', // ✅ Type correct
  16. completed: '1', // ❌ 'completed' does not exist in type 'T1Obj'
  17. }
  18. // 3️⃣ `T[P]` 作用是获取 "对象类型 T" 的 "键",举个栗子
  19. type TodoPro = {
  20. title: string
  21. desc: string
  22. completed: boolean
  23. timestamp: number
  24. }
  25. type T2 = TodoPro['title'] // = string
  26. type T3 = TodoPro['desc'] // = string
  27. type T4 = TodoPro['completed'] // = boolean
  28. type T5 = TodoPro['timestamp'] // = number
  29. type T6 = TodoPro['wth'] // ❌ Property 'wth' does not exist on type 'TodoPro'

综合拆解后的分析来看,[P in keyof T]?: T[P] 其实就是:

  • 构造的新的对象类型,其 key 都是 Tkey,不多不少。
  • 构造的新的对象类型,其 value 的类型与 T 中对应 key 的类型相同。
  • key 都给加了个问号,表示这个构造的新的对象类型的 key 都是可选的。

2、Require<T>

  1. /**
  2. * Make all properties in T required
  3. */
  4. type Required<T> = {
  5. [P in keyof T]-?: T[P];
  6. };

看起来跟 Partial<T> 源码实现的差别就只是:

  • “将 ? 替换成了 -? “。

表示有点懵:

  • ? 经常看到,可 -? 是个啥玩意 …

谷歌一波后找到了个靠谱答案( What does -? mean in TypeScript ? ),大意如下:

  • 我们可以通过 +- 来控制「映射类型修饰符」比如 ?readobly
  • 其中 -? 意为着 “必须全部存在”,也就是说它帮你把 “可选性 ?“ 都给删掉了。
  • 其中 +? 意味着 “全部都是可选的”,也就是说它帮你给没有 “可选性 ?“ 的 key 加上了可选性。

代码演示如下:

  1. type T = {
  2. a: string
  3. b?: string
  4. }
  5. // 此时 T1 和 T 相同:`a` 为 "必选",`b` 为 "可选"
  6. const T1: { [P in keyof T]: string } = {
  7. a: 'AA',
  8. }
  9. // 酱紫 `a` 变为 "可选" 了
  10. const T2: { [P in keyof T]+?: string } = {
  11. }
  12. // 酱紫 `a` 变为 "可选" 了(`+?` 简写成 `?`)
  13. const T3: { [P in keyof T]?: string } = {
  14. }
  15. // 酱紫 `b` 变为 "必选" 了
  16. const T4: { [P in keyof T]-?: string } = {
  17. a: 'AA',
  18. b: 'BB',
  19. }

3、Readonly<T>

  1. /**
  2. * Make all properties in T readonly
  3. */
  4. type Readonly<T> = {
  5. readonly [P in keyof T]: T[P];
  6. };

这个也有点类似 Partial<T> ,只是将 key 的修饰符 ? 替换成了 readonly

4、Record<T>

Record<T> 源代码:

  1. /**
  2. * Construct a type with a set of properties K of type T
  3. */
  4. type Record<K extends keyof any, T> = {
  5. [P in K]: T;
  6. };

有点意思,在泛型声明中多了个 extends 关键字,而且是 keyof 的是 any ,为啥要这样? 下面我们也是拆解后来看。

extends

先来看下通过 interface 关键字声明类型的一个姿势:

  1. interface T1 {
  2. x: number
  3. y: number
  4. }
  5. interface T2 extends T1 {
  6. z: number
  7. }
  8. // 此时 T2 等效于下面的定义
  9. interface T2 extends T1 {
  10. x: number
  11. y: number
  12. z: number
  13. }

上面的 T2 通过 extends 关键字继承了 T1 所有的属性,那问题来了:

  • 从 “表示范围” 来看,T1 大还是 T2 大?

答案是:

  • T1 的表示范围比 T2 的大。

可是你会想到:

  • T2 不是比 T1 多了 z: number 么?

是的,就因为多了个 z: number,所以 T2 表示的范围就小了。举个栗子:

  • 现在广场上有 10 个人,其中 3 个拿着苹果和西瓜,3 个拿着西瓜和橘子,剩余 4 个拿着橘子和苹果。
  • 你大喊一声:拿着苹果的走出来,此时共有 7 个人。
  • 然后你在大喊一声:拿着苹果和西瓜的走出来,此时共有 3 个人。
  • 紧接着又大喊一声:拿着苹果、西瓜和橘子的走出来,此时共有 0 个人。

所以说:

  • 当你的限定条件越来越多时,你圈中的范围就会越来越小。

结合上面声明类型的示例代码,在进行「条件类型检查」时你会得到这样的结果:

  1. interface T1 {
  2. x: number
  3. y: number
  4. }
  5. interface T2 extends T1 {
  6. z: number
  7. }
  8. // 结果:
  9. type IsT1ExtendsT2 = T1 extends T2 ? true : false // = false
  10. type IsT2ExtendsT1 = T2 extends T1 ? true : false // = true

由此可以得出结论:

  • 对象类型中,多一个 key 之后表示的范围会比原来的小。

然后,再来看下在「联合类型」中进行「条件类型检查」的结果是怎样:

  1. type T1 = 'first' | 'second' | 'third'
  2. type T2 = 'first' | 'second'
  3. // 结果:
  4. type IsT1ExtendsT2 = T1 extends T2 ? true : false // = false
  5. type IsT2ExtendsT1 = T2 extends T1 ? true : false // = true

从结果看,我们可以得出另外一个结论:

  • 联合类型中,多一个 成员之后表示的范围会比原来的大。

keyof any

上面的 extends 部分我们能够理解了,接下来看 keyof any。回想一下:

  • JavaScript 对象中的 key 有哪几种类型?

答案是:

  • 3 种,stringnumbersymble

举个栗子:

  1. const num = 123
  2. const symble = Symbol('This is a Symble')
  3. const obj = {
  4. name: '哈哈',
  5. [num]: '呵呵',
  6. [symble]: '呃..'
  7. }
  8. console.log(obj.name) // '哈哈'
  9. console.log(obj[num]) // '呵呵'
  10. console.log(obj[symble]) // '呃..'

实际上,keyof any 就可以用来表示 “能够作为对象的 key “ 的类型,即 string | number | symbol ,不信你看 😏 :
image.png

小结

综合上面的 extendskeyof any 的分析,可知只要传入的 Record<K extends keyof any, T> 中的 K 的范围小于联合类型 string | number | symbol,那么类型检查就是正确的。举些栗子:

  1. type Todo = {
  2. title: string
  3. desc: string
  4. }
  5. type T1 = Record<'first', Todo> // ✅ Type correct
  6. type T2 = Record<'first'| 1234567, Todo> // ✅ Type correct
  7. type T3 = Record<'first' | 1234567 | symbol, Todo> // ✅ Type correct
  8. const myTodo = {
  9. title: '吃块西瓜',
  10. desc: '今天中午吃块 🍉 吧'
  11. }
  12. const myNum = 1234567
  13. const mySymble = Symbol('This is my symble')
  14. const t4: T4 = {
  15. first: myTodo,
  16. [myNum]: myTodo,
  17. [mySymble]: myTodo
  18. }
  19. type T4 = Record<{ a: number }, Todo> // ❌ Type '{ a: number; }' does not satisfy the constraint 'string | number | symbol'
  20. type T5 = Record<() => void, Todo> // ❌ Type '() => void' does not satisfy the constraint 'string | number | symbol'

5、Pick<T>

  1. /**
  2. * From T, pick a set of properties whose keys are in the union K
  3. */
  4. type Pick<T, K extends keyof T> = {
  5. [P in K]: T[P];
  6. };

如果前面的 Record<K extends keyof any, T> 源码实现都看懂了,这个跳过就好,哈哈。

6、Omit<T>

  1. /**
  2. * Construct a type with the properties of T except for those in type K.
  3. */
  4. type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

等号左右的 K extends keyof any 我们知道是:

  • K 的范围限定在 string | number | symbol 之下。

等号右边的感觉有点绕,我们结合一个实例来看下:

  1. interface Todo {
  2. title: string
  3. desc: string
  4. completed: boolean
  5. }
  6. type Todo2 = Omit<Todo, 'desc'>

在该实例中我们可以看出:

  • keyof T 等效于 'title' | 'desc' | 'completed'
  • K 等效于 desc

也就是说,Exclude<keyof T, K> 实际上:

  • 等效于 Exclude<'title' | 'desc' | 'completed', 'desc'>
  • 即等效于 'title' | 'completed'

这样的话,等号右边的 Pick<T, Exclude<keyof T, K>> 实际上:

  • 等效于 Pick<Todo, 'title' | 'completed'>

综合来看可以得出结论:

  • Omit 实际上是通过 Exclude 实现的一个 “反转 Pick“ 。

7、Exclude<T>

  1. /**
  2. * Exclude from T those types that are assignable to U
  3. */
  4. type Exclude<T, U> = T extends U ? never : T;

可以看到,这里通过 extends 关键字进行了「条件类型检查」:

  • T 的范围小于 U 时,可以理解成是把 T 中所有的成员排除掉,没有成员后就 “没有能表示其类型的类型” 了,于是结果为 never
  • T 的范围大于 U 时,结果却还是 T ,怎么理解?

回到我们之前的实例代码:

  1. type Days = 'Mon.' | 'Tues.' | 'Wed.' | 'Thurs.' | 'Fri.' | 'Sat.' | 'Sun.'
  2. type Weekend = 'Sat.' | 'Sun.'
  3. type Workday = Exclude<Days, Weekend>
  4. // 结果:
  5. // type Workday = 'Mon.' | 'Tues.' | 'Wed.' | 'Thurs.' | 'Fri.'

此时在 Exclude<Days, Weekend> 中:

  • DaysTWeekendK
  • Days 的范围比 Weekend 大,从源码逻辑来看结果应该是 Days 才对,但实际上却不是。确实有点难理解,没关系,继续往下看 …

在 TypeScript 中有一个「条件类型约束」的概念(详见 官方文档:Conditional Type Constraints):

  • 「条件类型检查」通常会给我们提供一些新的约束信息,比如:
    • “类型保护缩小(narrowing with type guards)” 给我们一个更具体的类型。
    • 类型检查结果为 true 的 “分支” 会进一步约束泛型。

以源码中的 T extends U ? never : T 为栗,我们写个测试代码看看:

  1. type T = 'A' | 'B' | 'C'
  2. type U = 'A' | 'C'
  3. type GuessWhatIam = T extends U ? never : T
  4. // 预期:type GuessWhatIam = 'B'
  5. // 实际:type GuessWhatIam = 'A' | 'B' | 'C'

呃… GuessWhatIam 并不是我们预期的 'B',翻车了?并没翻,因为这里的 T extedns U ? 实际上起到的是一个 “类型判定” 的作用,把判定的结果 T 直接赋给了 GuessWhatIam,就像我们使用三元表达式 const c = a > b ? a : b 一样。

因为这里的 TU 都是具体的某个类型,并不是范式类型(泛型)。而真正能让魔法(进一步约束类型)发生的前提条件是:

  • TU 都得是泛型。

再来一段测试代码:

  1. // 自定义一个 Exclude 工具类型
  2. type CustomExclude<GenericT, GenericU> = GenericT extends GenericU ? never : GenericT
  3. type T = 'A' | 'B' | 'C'
  4. type U = 'A' | 'C'
  5. type GuessWhatIam = CustomExclude<T, U>
  6. // 预期:type GuessWhatIam = 'B'
  7. // 实际:type GuessWhatIam = 'B'

嗯,This is the expected magic 🤒

所以说,要想真正理解 「工具类型」的源码实现,还是得看认真了解官方文档的一些细节,并在实际中检验才行。

8、Extract<T>

  1. /**
  2. * Extract from T those types that are assignable to U
  3. */
  4. type Extract<T, U> = T extends U ? T : never;

可见跟 Exclude 的源代码差不多,都是通过 “泛型的进一步约束” 来获得更加具体的 T

9、NonNullable<T>

  1. /**
  2. * Exclude null and undefined from T
  3. */
  4. type NonNullable<T> = T extends null | undefined ? never : T;

哈,其实 NonNullable<T> 本质上跟 Exclude<T> 的实现是一样的,只是前者将后者的 K 具体成了 null | undefined 而已。这样就可以通过 “进一步约束泛型” 来排除掉了 T 中原有的 nullundefined 了。

10、Parameters<T>

  1. /**
  2. * Obtain the parameters of a function type in a tuple
  3. */
  4. type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

Parameters<T> 的实现姿势咋一眼看起来跟 Extract<T> 的很像,毕竟这两个「工具类型」都是有点 “提取” 意思在(前者提取 “函数参数类型”,后者提取 “联合类型的子集”)。可再仔细一看,发现有个 infer 关键字看起来好陌生,我们到 官方文档:Inferring Within Conditional Types 看下这玩意儿是个啥?

概括来说:

  • infer 关键字其实是 TypeScript 提供的一种 “在「条件类型检查中」中声明「待推断类型」变量” 的机制。

结合上面的源码实现来看:

  • Parameters 限定了 T 的范围是 (...args: **any**) => any
  • T 的范围小于 (...args: **infer P**) => any 时,结果为 P;否则,结果为 never

举倆栗子:

  1. // Example 1
  2. type T = (a: string) => void
  3. type GuessWhatIam = Parameters<T>
  4. // 此时 `T` 参数列表类型实际上是元组 `[a: string]`,
  5. // 所以这个待 infer 的 `P` 是 `[a: string]`
  6. // 即 GuessWhatIam 等效于下面:
  7. // type GuessWhatIam = [a: string]
  8. // Example 2
  9. type T = (a: string, b: number) => void
  10. type GuessWhatIam = Parameters<T>
  11. // 此时 `T` 参数列表类型实际上是元组 `[a: string, b: number]`,
  12. // 所以这个待 infer 的 `P` 是 `[a: string, b: number]`
  13. // 即 GuessWhatIam 等效于下面
  14. // type GuessWhatIam = [a: string, b: number]

11、ReturnType<T>

  1. /**
  2. * Obtain the return type of a function type
  3. */
  4. type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

这跟 Parameters 的逻辑基本相同,只不过这次 “待 infer 的变量” 是函数的返回值,而不是参数列表。

四、总结

到这里我们基本上已经理解了 TypeScript 中「工具类型」的使用,也知晓了其源码的大致实现。

是时候说下次见了,Have a nice dinner my friend. 🚀