Search K
Appearance
Appearance
我们学会了 6 个类型体操的套路,各种高级类型都能写出来,也知道了类型体操的意义(类型之间有关联的时候必须要类型编程,用类型编程能做到更精准的类型提示和检查),但是做的练习还是不够多。
前面的案例更多是用于讲某个套路的,这节开始我们做一些比较综合的案例。
常用的变量命名规范有两种,一种是 KebabCase,也就是 aaa-bbb-ccc 这种中划线分割的风格,另一种是 CamelCase, 也就是 aaaBbbCcc 这种除第一个单词外首字母大写的风格。
如果想实现 KebabCase 到 CamelCase 的转换,该怎么做呢?
比如从 guang-and-dong 转换成 guangAndDong。
这种明显是要做字符串字面量类型的提取和构造,并且因为单词数量不确定,要递归地处理。
所以是这样写:
type KebabCaseToCamelCase<Str extends string> = Str extends `${infer Item}-${infer Rest}`
? `${Item}${KebabCaseToCamelCase<Capitalize<Rest>>}`
: Str;类型参数 Str 是待处理的字符串类型,约束为 string。
通过模式匹配提取 Str 中 - 分隔的两部分,前面的部分放到 infer 声明的局部变量 Item 里,后面的放到 infer 声明的局部变量 Rest 里。
提取的第一个单词不大写,后面的字符串首字母大写,然后递归的这样处理,然后也就是 `${Item}${KebabCaseToCamelCase`。
如果模式匹配不满足,就返回 Str。
这样就完成了 KebabCase 到 CamelCase 的转换:

那反过来怎么转换呢?我们再实现下 CamelCase 到 KebabCase 的转换:
同样是对字符串字面量类型的提取和构造,也需要递归处理,但是 CamelCase 没有 - 这种分割符,那怎么分割呢?
可以判断字母的大小写,用大写字母分割。
也就是这样:
type CamelCaseToKebabCase<Str extends string> = Str extends `${infer First}${infer Rest}`
? First extends Lowercase<First>
? `${First}${CamelCaseToKebabCase<Rest>}`
: `-${Lowercase<First>}${CamelCaseToKebabCase<Rest>}`
: Str;类型参数 Str 为待处理的字符串类型。
通过模式匹配提取首个字符到 infer 声明的局部变量 First,剩下的放到 Rest。
判断下当前字符是否是小写,如果是的话就不需要转换,递归处理后续字符,也就是 `${First}${CamelCaseToKebabCase}`。
如果是大写,那就找到了要分割的地方,转为 - 分割的形式,然后把 First 小写,后面的字符串递归的处理,也就是 `-${Lowercase}${CamelCaseToKebabCase}`。
如果模式匹配不满足,就返回 Str。
这样就完成了 CamelCase 到 KebabCase 的转换:

做了两个字符串类型的练习,再来做个数组类型的:
希望实现这样一个类型:
对数组做分组,比如 1、2、3、4、5 的数组,每两个为 1 组,那就可以分为 1、2 和 3、4 以及 5 这三个 Chunk。
这明显是对数组类型的提取和构造,元素数量不确定,需要递归的处理,并且还需要通过构造出的数组的 length 来作为 chunk 拆分的标志。
所以这个类型逻辑这么写:
type Chunk<
Arr extends unknown[],
ItemLen extends number,
CurItem extends unknown[] = [],
Res extends unknown[] = []
> = Arr extends [infer First, ...infer Rest]
? CurItem["length"] extends ItemLen
? Chunk<Rest, ItemLen, [First], [...Res, CurItem]>
: Chunk<Rest, ItemLen, [...CurItem, First], Res>
: [...Res, CurItem];类型参数 Arr 为待处理的数组类型,约束为 unknown。类型参数 ItemLen 是每个分组的长度。
后两个类型参数是用于保存中间结果的:类型参数 CurItem 是当前的分组,默认值 [],类型参数 Res 是结果数组,默认值 []。
通过模式匹配提取 Arr 中的首个元素到 infer 声明的局部变量 First 里,剩下的放到 Rest 里。
通过 CurItem 的 length 判断是否到了每个分组要求的长度 ItemLen:
如果到了,就把 CurItem 加到当前结果 Res 里,也就是 […Res, CurItem],然后开启一个新分组,也就是 [First]。
如果没到,那就继续构造当前分组,也就是 […CurItem, First],当前结果不变,也就是 Res。
这样递归的处理,直到不满足模式匹配,那就把当前 CurItem 也放到结果里返回,也就是 […Res, CurItem]。
这样就完成了根据长度对数组分组的功能:

字符串类型、数组类型都做了一些练习,接下来再做个索引类型的:
我们希望实现这样一个功能:
根据数组类型,比如 ['a', 'b', 'c'] 的元组类型,再加上值的类型 'xxx',构造出这样的索引类型:
{
a: {
b: {
c: "xxx";
}
}
}这个依然是提取、构造、递归,只不过是对数组类型做提取,构造索引类型,然后递归的这样一层层处理。
也就是这样的:
type TupleToNestedObject<Tuple extends unknown[], Value> = Tuple extends [infer First, ...infer Rest]
? {
[Key in First as Key extends keyof any ? Key : never]: Rest extends unknown[]
? TupleToNestedObject<Rest, Value>
: Value;
}
: Value;类型参数 Tuple 为待处理的元组类型,元素类型任意,约束为 unknown[]。类型参数 Value 为值的类型。
通过模式匹配提取首个元素到 infer 声明的局部变量 First,剩下的放到 infer 声明的局部变量 Rest。
用提取出来的 First 作为 Key 构造新的索引类型,也就是 Key in First,值的类型为 Value,如果 Rest 还有元素的话就递归的构造下一层。
为什么后面还有个 as Key extends keyof any ? Key : never 的重映射呢?
因为比如 null、undefined 等类型是不能作为索引类型的 key 的,就需要做下过滤,如果是这些类型,就返回 never,否则返回当前 Key。
这里的 keyof any 在内置的高级类型那节也有讲到,就是取当前支持索引支持哪些类型的:

如果提取不出元素,那就构造结束了,返回 Value。
这样就实现了根据元组构造索引类型的功能:

当传入 number 时:

当传入 undefined 时:

我们再来练习下内置的高级类型,我们对这块的练习比较少:
我们想实现这样一个功能:
把一个索引类型的某些 Key 转为 可选的,其余的 Key 不变,
比如
interface Dong {
name: string;
age: number;
address: string;
}把 name 和 age 变为可选之后就是这样的:
interface Dong2 {
name?: string;
age?: number;
address: string;
}这样的类型逻辑很容易想到是用映射类型的语法构造一个新的类型。
但是我们这里要求只用内置的高级类型来实现。
那要怎么做呢?
内置的高级类型里有很多处理映射类型的,比如 Pick 可以根据某些 Key 构造一个新的索引类型,Omit 可以删除某些 Key 构造一个新的索引类型,Partial 可以把索引类型的所有 Key 转为可选。
综合运用这些内置的高级类型就能实现我们的需求:
我们先把 name 和 age 这俩 Key 摘出来构造一个新的索引类型:

然后把剩下的 Key 摘出来构造一个新的索引类型:

把第一个索引类型转为 Partial,第二个索引类型不变,然后取交叉类型。
交叉类型会把同类型做合并,不同类型舍弃,所以结果就是我们需要的索引类型。
type PartialObjectPropByKeys<Obj extends Record<string, any>, Key extends keyof any> = Partial<
Pick<Obj, Extract<keyof Obj, Key>>
> &
Omit<Obj, Key>;类型参数 Obj 为待处理的索引类型,约束为 Record<string, any>。
类型参数 Key 为要转为可选的索引,那么类型自然是 string、number、symbol 中的类型,通过 keyof any 来约束更好一些。默认值是 Obj 的索引。
keyof any 是动态返回索引支持的类型,如果开启了 keyOfStringsOnly 的编译选项,那么返回的就是 string,否则就是 string | number | symbol 的联合类型,这样动态取的方式比写死更好。
Extract 是用于从 Obj 的所有索引 keyof Obj 里取出 Key 对应的索引的,这样能过滤掉一些 Obj 没有的索引。

从 Obj 中 Pick 出 Key 对应的索引构造成新的索引类型并转为 Partial 的,也就是 Partial<Pick<Obj,Extract<keyof Obj, Key>>>,其余的 Key 构造一个新的索引类型,也就是 Omit<Obj,Key>。然后两者取交叉就是我们需要的索引类型:

为啥这里没计算出最终的类型呢?
因为 ts 的类型只有在用到的的时候才会去计算,这里并不会去做计算。我们可以再做一层映射,当构造新的索引类型的时候,就会做计算了:
type Copy<Obj extends Record<string, any>> = {
[Key in keyof Obj]: Obj[Key];
};
type PartialObjectPropByKeys<Obj extends Record<string, any>, Key extends keyof any = keyof Obj> = Copy<
Partial<Pick<Obj, Extract<keyof Obj, Key>>> & Omit<Obj, Key>
>;这里的 Copy 就是通过映射类型的语法构造新的索引类型,key 和 value 都不变。
这样就会计算出最终的索引类型:

当然,这里的 Copy 也可以不加,并不影响功能。
我们学完了类型编程的套路,也知道了类型编程的意义(类型有关联的时候必须用类型编程,类型编程可以实现更精准的类型提示和检查),但是做的综合一些的案例还是少,这节就各种类型的类型编程都做了一遍。
包括字符串类型、数组类型、索引类型的构造、提取,都涉及到了递归,也对内置的高级类型做了练习。
这一节的类型练下来,相信你会对类型编程会更加得心应手了。