[ TS 类型体操] 初体验之 Pick 与 Omit

2,019 阅读6分钟

前言

Typescript 逐渐成为前端工程师必备的技能,如何更好的理解 Typescript,使用它解析标注更为复杂的类型?类型体操是一个很好的选择。

如何利用 Typescript 的特性来实现一系列令人眼界大开的操作,这个库 type-challenges 将告诉你答案

在这篇文章中,我将从最基础的开始,来一步步完善我们的 Typescript 操作手段

Pick<T, K> [easy]

在这道题目中,我们将实现一个内置的泛型 Pick<T, K>,它的功能是从 T 中选出 K 中的属性,并将选中的属性返回。 问题链接

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyPick<Todo, 'title' | 'completed'>

const todo: TodoPreview = {
    title: 'Clean room',
    completed: false,
}

测试用例如下

import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<Expected1, MyPick<Todo, 'title'>>>,
  Expect<Equal<Expected2, MyPick<Todo, 'title' | 'completed'>>>,
  // @ts-expect-error
  MyPick<Todo, 'title' | 'completed' | 'invalid'>,
]

interface Todo {
  title: string
  description: string
  completed: boolean
}

interface Expected1 {
  title: string
}

interface Expected2 {
  title: string
  completed: boolean
}

在我们未学习 TS 类型体操之前,看到这道题可能会有点蒙,我们不妨先使用 JS 的思维来分解一下这道题,先使用 JS 的代码来解决这道题。

根据题意以及测试用例,我们可以得到以下思路:

  1. 我们需要返回一个对象
  2. 我们需要遍历 K
  3. 如果 K 中的元素也在 T 中则将结果添加进 result
  4. 如果 K 中有 T 中没有的元素,需要抛出一个错误

根据以上信息我们可以很容易的写出以下代码

function myPick(todo, keys) {
  let result = {};

  keys.forEach((key) => {
    if (key in todo) picked[key] = todo[key]; 
  });

  return result;
}

JS 版本的 Pick 已经完成,我们来一步步完成 TS 版本的 Pick

返回一个对象

在 TS 中我们想要返回一个对象,可以直接这样写

type MyPick<T, K> = {};

这样我们第一个点已经完成,接下来我们开始完成第二个点

遍历 K

在 TS 中我们想要遍历一个对象可以使用 Mapped type 来进行遍历,即

type MyPick<T, K> = {
  [P in K]: T[P] 
};

TS 中,当我们想遍历一个对象的属性,我们可以使用 [P in K] 的方式来进行遍历。其中 P 代表着要遍历的属性中的一个。值得注意的是,在 in 右边的 K 必须是一个 union, 当他不是一个 union 时,我们需要使用 keyof 来将 K 转换为一个 union

完成这步之后,我们就可以利用 T[P] 来得到该元素的类型

限制 K 的范围

完成上一步时,我们可以发现我们已经可以完成大部分测试用例,但并未对 K 的范围进行限制。这时候我们就可以利用 extends 来进行条件约束

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P] 
};

需要注意的是 K 是一个 union,keyof T 也将 T 中的属性转换为一个 union。当我们使用 extends 来进行条件约束的时候,TS 会使用 union 分发的特性自动遍历 union K 中的属性与 keyof T 中的属性进行比较。

假设 K 为 'title' | 'completed' | 'invalid' ,T 为 'title' | 'completed' | 'description'。它的过程如下

step1:  'title' extends 'title' | 'completed' | 'description' //通过
step2:  'completed' extends 'title' | 'completed' | 'description' //通过
step3:  'invalid' extends 'title' | 'completed' | 'description' //未通过,报错

如果比较成功则通过,失败则报错,这样我们就实现了所有的关键步骤,通过了所有的测试用例。

通过一步步的拆解,我们可以看到这道题还是很简单的。那让我们来看下一道题

Omit<T, K> [medium]

在这道题中,我们需要实现一个内置泛型 Omit<T, K>,它的功能是从 T 中删除 K 包括的元素,最后将结果以对象的形式返回,问题链接

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyOmit<Todo, 'description' | 'title'>

const todo: TodoPreview = {
  completed: false,
}

测试用例如下:

import type { Equal, Expect } from '@type-challenges/utils'

type cases = [
  Expect<Equal<Expected1, MyOmit<Todo, 'description'>>>,
  Expect<Equal<Expected2, MyOmit<Todo, 'description' | 'completed'>>>,
]

// @ts-expect-error
type error = MyOmit<Todo, 'description' | 'invalid'>

interface Todo {
  title: string
  description: string
  completed: boolean
}

interface Expected1 {
  title: string
  completed: boolean
}

interface Expected2 {
  title: string
}

同上一道题一样,我们先分析一下他有哪些点需要实现

  1. 需要返回一个对象
  2. 遍历 T
  3. 如果 T 中的元素不在 K 中,则将该元素添加到结果中
  4. 如果 K 中包括 T 中没有的元素,则报错

同样,我们先使用 JS 实现一边来加强我们的理解

function omit(T, K) {
  let result

  T.foreach((item)=>{
    if(!K.includes(item)) {
      result.push(item)
    }
  })

  return result
}

我们来一步步实现上面总结的步骤

返回一个对象

通过 Pick 我们已经对这个很了解了

type MyOmit<T, K> = {}

遍历 T

通过上一道题,我们也可以很容易的写出一下 TS 代码

type MyOmit<T, K> = {
  [P in keyof T]: T[P]
}

需要注意的是,这次我们遍历的是 T 而不是 K,因为 T 是一个对象,所以我们要使用 keyof 来将他转化为一个 union

如果 T 中的元素不在 K 中,则将该元素添加到结果中

到这一步我们该怎么实现呢?我们想一下有什么方法可以使一个元素消失。对,我们可以使用 as never 来使一个元素消失。

在 TS 中如果一个 union 中的元素是一个 never 类型的,那么 TS 认为这个元素是一个空值,会返回去除这个值之后的结果。

type test<T> = {
  [P in keyof T]: T[P]
}

type foo = string | never
type r = test2<foo> // type a = string

所以根据以上特点,而在遍历 T 的过程中,一个 P 就是 union 中的一个元素,我们将这个元素断言成 never 那么他就会被 TS 看作是一个空的 union 元素,一个空的 union 元素自然不会被当作 key,也不会被 T[P] 所选中。这样我们就可以实现从 T 中删除一个元素的功能

type MyOmit<T, K> = {
  [P in keyof T as P extends K ? never : P]: T[P]
}

以上就是该功能点的全部实现,注意的是,我们根据上一题已经知道了,当 extends 两边是一个 union 时,就会触发 union 的分发特性(Distributive),P 中的元素会自动与 K 中的元素进行对比,如果对比通过了,说明该元素是要被删除的,则将该元素断言成 never,如果未通过,还按原来的结果处理

当 K 中不包括 T 中的属性报错

根据第一题,很容易的就可以实现

type MyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P]: T[P]
}

至此,Pick 与 Omit 已经全部实现完成

总结

  1. 使用 keyof 对类型进行提取
  2. 使用 extends 对类型进行限制的两种方法
  1. 使用 T[P] 来获得类型
  1. as never 来将 union 中的元素去除
  1. 使用 in 来进行遍历

这是我自己的练题仓库,里面会总结我在练习类型体操中遇到的相关问题以及相关知识点,如果这对你有帮助的话就给我一个 star 吧😄