Featured image of post TypeScript 类型编程进阶

TypeScript 类型编程进阶

花里胡哨的类型系统你真的会用吗?

前言

ts 大家或多或少都在项目中使用过了,但是可能大家只是感受了 ts 的代码提示和静态检查特性,却没有深入接触 ts 强大的类型系统。遇到一些复杂的场景时,甚至无法很好地写出类型,而只能草草用 any 替代。

今天我将从几个有趣的例子出发,逐步解析 ts 的高级用法,并且告诉大家类型体操的必要性

在阅读本篇文章之前,我假定您已经对 ts 的泛型语法以及一些常用关键字有所了解,如果您还不是很了解这些,可以先阅读我之前的文章:TypeScript 进阶

正文

在写项目的过程中,我经常会遇到这样一个问题——在使用 zustand 或者其他的状态管理库时,ts 类型需要同时提供 gettersetter,大概是这样一个情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
interface RootStore {
  userInfo: UserInfo;
  setUserInfo: (userInfo: UserInfo) => void;
  count: number;
  setCount: (count: number) => void;
}

const useRootStore = create<RootStore>()(
  devtools(
    persist(set => ({
      userInfo: {} as UserInfo,
      setUserInfo: userInfo => {
        set({ userInfo });
      },
      count: 0,
      setCount: count => {
        set({ count });
      },
    }))
  )
);

然后我有时需要拿到全部的数据,而不需要对应数据的 setteractions in other words ),所以我自然而然想到了两种写法:

1
2
3
type Filtered = Omit<RootStore, "setUserInfo" | "setCount">;

type Filtered = Pick<RootStore, "userInfo" | "count">;

这里的 OmitPick 都是 ts 内置的工具类型,前者用与从类型中排除指定项,后者则用于从类型中选出指定项。

经过上述操作,最终得到了类型 Filtered 就只有 userInfocount 两项了。但是如果数据很多,这样的写法显得费时费力了,所以我们需要一个新的工具类型来实现。

观察代码,我们发现需要去除的类型都是以 set 开头的,所以我们也许可以写出一个 MatchKeyPrefix 类型来解决:

1
2
3
4
type MatchKeyPrefix<T, Prefix extends string> = any;

// 用法
type data = MatchKeyPrefix<RootStore, "set">;

这里我们显然需要对类型所有的键进行遍历,拿到键名然后比较前缀,先写一个基本的框架

1
2
3
type MatchKeyPrefix <T, Prefix extends string> = {
  [K in keyof T]: someReg
}

这里有一个小技巧,我们可以使用 extends 运算符和模板字符串来对字符串进行匹配,比如这样一个经典的类型工具 TrimStart

1
2
type TrimStart<S extends string, P extends string> =
  S extends `${P}${infer R}` ? TrimStart<R, P> : S

这里的 TrimStart 类型将 Pinfer R 通过模板字符串进行组合,infer R 会匹配出去除 P 前缀后的子串,并且递归地去除匹配的前缀,直到全部都被删除为止。

那将其简单改造一下就可以变成一个匹配前缀的类型了

1
2
3
type MatchKeyPrefix <T, Prefix extends string> = {
  [K in keyof T]: K extends `${Prefix}${string}` ? K : never
}

这里实现的就是判断 K 是否以 Prefix 开头,如果是则返回本身,不是则返回 never

为什么要返回 never 呢,这里就要涉及到 ts 的索引访问方式了

类似于 JSON.stringify 中值为 undefined 的键也会被自动去除一样,ts 在索引访问操作中同样不会取得值为 never 的类型,看下面的例子

1
2
3
4
5
type Value = {
  name: string
}["name"]

// 等价于 type Value = string

类似 js 中对象的索引访问方式,不同的是括号中的键名可以用 | 分隔以同时取得多个值

1
2
3
4
5
6
type Value = {  
  name: string;   
  age: number 
}["name" | "age"]

// 等价于 type Value = string | number

那么如果值为 never 类型,则不会被访问,最终得到的联合类型,就实现了自动筛选

1
2
3
4
5
6
7
type Value = {  
  name: string;   
  age: never 
}["name" | "age"]

// 等价于 type Value = string
// never 类型不会被取出

所以我们可以利用这个特性,将匹配上前缀的类型返回原本类型,并将无法匹配的类型返回 never 类型,再通过其索引访问原类型,就可以得到匹配上前缀的类型了

首先拿到索引

1
2
3
type MatchKeyPrefix <T, Prefix extends string> = {
  [K in keyof T]: K extends `${Prefix}${string}` ? K : never
}[K in keyof T]

再通过索引访问原类型,这里可以使用 Pick 简化

1
2
3
4
5
6
type MatchKeyPrefix<T, Prefix extends string> = Pick<
  T,
  {
    [K in keyof T]: K extends `${Prefix}${string}` ? K : never
  }[keyof T]
>

至此,我们就实现了前缀的匹配,调用方式如下:

1
MatchKeyPrefix<RootStore, 'set'>

通过测试,我们可以看出这个工具完美地实现了我们的需求

Type Challenge - TypeScript PlayGround

那么想要反选,则只需要再加上一层 Omit 就可以了

1
2
3
4
// 封装一下
type FilterMatchKeyPrefix<T, K extends string> = Omit<T, keyof MatchKeyPrefix<T, K>> 

type newType = FilterMatchKeyPrefix<RootStore, 'set'>

同理,我们还可以根据这一套逻辑再写出一个 FilterConditionally,这个类型可以筛选出指定类型的字段,例如

1
2
3
4
5
6
7
8
interface Example {
    a: string; // ✅
    b: string; // ✅
    c: number; // ❌
    d: boolean; // ❌
}

type NewType = FilterConditionally<Example, string>

就可以从 Example 类型中筛选出所有类型为 string 的字段。而它的实现则和上文提到的 MatchKeyPrefix 类型几乎一样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type FilterConditionally<Source, Condition> = Pick<
  Source,
  {
    [K in keyof Source]: Source[K] extends Condition ? K : never
    /**
     * diff
     * K extends `${Prefix}${string}` ? K : never
     */
  }[keyof Source]
>;

这里的整体逻辑和上文几乎一致,核心就在于这一句

1
Source[K] extends Condition ? K : never

逻辑就是符合传入类型就返回原类型,否则返回 never,再配合外层的判断,就可以把所有符合的类型过滤出来了。

参考文章:React 中的 TS 类型过滤原来是这么做的!-51CTO.COM

Built with Hugo
主题 StackJimmy 设计