前言
ts 大家或多或少都在项目中使用过了,但是可能大家只是感受了 ts 的代码提示和静态检查特性,却没有深入接触 ts 强大的类型系统。遇到一些复杂的场景时,甚至无法很好地写出类型,而只能草草用 any 替代。
今天我将从几个有趣的例子出发,逐步解析 ts 的高级用法,并且告诉大家类型体操的必要性
在阅读本篇文章之前,我假定您已经对 ts 的泛型语法以及一些常用关键字有所了解,如果您还不是很了解这些,可以先阅读我之前的文章:TypeScript 进阶
正文
在写项目的过程中,我经常会遇到这样一个问题——在使用 zustand
或者其他的状态管理库时,ts 类型需要同时提供 getter
和 setter
,大概是这样一个情况:
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 });
},
}))
)
);
|
然后我有时需要拿到全部的数据,而不需要对应数据的 setter
( actions
in other words ),所以我自然而然想到了两种写法:
1
2
3
|
type Filtered = Omit<RootStore, "setUserInfo" | "setCount">;
type Filtered = Pick<RootStore, "userInfo" | "count">;
|
这里的 Omit
和 Pick
都是 ts 内置的工具类型,前者用与从类型中排除指定项,后者则用于从类型中选出指定项。
经过上述操作,最终得到了类型 Filtered
就只有 userInfo
和 count
两项了。但是如果数据很多,这样的写法显得费时费力了,所以我们需要一个新的工具类型来实现。
观察代码,我们发现需要去除的类型都是以 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
类型将 P
与 infer 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'>
|
通过测试,我们可以看出这个工具完美地实现了我们的需求

那么想要反选,则只需要再加上一层 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