在 Javascript 中,变量名,键的命名风格一般都是 camelCase 的,而在许多接口规范中,则都是接受 PascalCase 风格的 JSON Object。完成大驼峰参数到小驼峰参数的转换,这在 Javascript 中是较容易实现的。可是在 Typescript 中,我们进行了这样的转换后,就会丢失类型的信息。这导致我们每次进行转换后,都必须重写类型,或是放弃后续的类型检查。
-
忽略后续类型违反了重构 Typescript 的初衷 这导致后续的代码都失去了类型检查的保护,在后续业务变更新增后续代码,更会导致更多的问题
-
重写类型导致需要进行小驼峰类型 <-> 大驼峰类型的重复编写工作 在编程中,可怕的不仅仅是重复工作导致的工作量增加,更严重的是,重复可能会带来错误:某个重复中的错误会导致错误难以排查,而多个重复中共有的错误,则会导致错误难以修正 这也就是违反了编程中我们常说的 DRY 原则 (Don't Repeat Yourself)
遗憾的是,这个问题在 Typescript 4.1 以前,是简单的没有解决方法的,不过幸运的是,4.1 版本已经进入了 stable 阶段,它带来的字符串模版类型 template literal type
可以让我们修改字符串常量的类型,进行各种强大的操作,例如,通过下面的 PascalCasedPros<T>
,我们就可以将小驼峰的接口类型中的每个键转为大驼峰:
type PascalCase<T> = T extends string
? T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
: T;
type PascalCasedProps<T> = T extends Function
? T
: T extends Array<infer U>
? Array<PascalCasedProps<U>>
: {
[K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>;
};
复制代码
咋一看其中内容是非常复杂且混乱的,但让我们以具体的代码为例子,具体的一步步来解析一下:
PascalCase<T>
将字符串常量类型 T
转换为对应的大驼峰版本
首先,我们需要一个能将一个字符串字面量 (string literal
) 类型转换为对应的大驼峰字符串字面量类型的泛型类型别名 generic type alias
。
简单来说,我们需要把诸如
type A = 'HelloWorld'
的类型A
,变为type B = 'helloWorld'
的类型B
而我们要的泛型类型别名,就是如下的 PascalCase<T>
:可以看到,我们传入一个字符串字面量类型,它就可以完成我们需要的转换。
type PascalCase<T> = T extends string
? T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
: T;
type CheckMe = PascalCase<"HelloWorld">;
// type CheckMe = "helloWorld"
复制代码
但它是如何工作的呢?让我们来逐步解析一下:
最外部是 T extends string ? (...) : T
这样的结构,这有点像三目运算符,实际上,它起的正是这样的作用:
- 首先,
A extends B
是判断A
类型是否扩展了B
类型(比如一个类实现了另一个接口),在这里,T extends string
也就是判断T
类型是否是string
- 如果不是 string,我们进入
: T
部分,很简单,即不做转换,直接返回T
类型
具体的,这里再举两个例子说明
extends
的作用:
{ a: string; b: number }
包含了{ b:number }
类型,故A
为true
type A = { a: string; b: number } extends { b: number } ? true : false; // type A = false 复制代码
{ }
未包含{ b:number }
类型,故B
为false
type B = {} extends { b: number } ? true : false; // type B = false; 复制代码
中间的部分则要复杂一些,涉及到字符串模板类型,让我们下面来看一下:
T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
复制代码
-
首先,这部分和之前一样,也是判断 T 是否扩展了
${infer A}${infer B}
类型T extends `${infer A}${infer B}` 复制代码
-
这里我们需要讲解一下
infer
在 typescript 中的作用,它代表让 TypeScript 的类型系统推测一个类型,也就是说,让编译器自动根据字符串字面量T
的结构,自动推出A
和B
对应的部分。更具体的,我们可以来看两个例子,说明
infer
的作用:-
提取函数第一个参数
type FirstParam<T> = T extends (firstParam: infer U) => any ? U : never; type CheckMe = FirstParam<(a: number) => {}>; // type CheckMe = number; 复制代码
在这里,我们告诉编辑器,如果
T
能够实现(firstParams: infer U) => any
的类型,那我们返回U
的类型(也就是函数第一个参数的类型)。在编辑器实际工作中,它发现令U = number
,T
即可实现后面的函数,这样,我们就达到了提取函数第一个参数类型的目的。 -
去除字符串字面量类型尾部
_id
type WithoutId<T> = T extends `${infer U}_id` ? U : T; type CheckMe = WithoutId<"user_id">; // type CheckMe = "user"; 复制代码
在这里,我们告诉编辑器,如果
T
能够实现${infer U}_id
的类型,就返回U
的类型。编辑器工作时,发现当U = user
时,user_id
自然等于${infer U}_id
,这样,我们就实现了去除字符串字面量类型尾部_id
-
-
理解了上面的内容,我们回过头来看完整的中间部分,就没有那么难以理解了
T extends `${infer A}${infer B}` ? `${Uppercase<A>}${B}` : T 复制代码
可以看到,在编辑器工作时,将
A
推断为第一个字符,B
推断为后续部分。(当不影响后续匹配时,每个推断类型会匹配尽量少且不为空串的子字符串,所以A
只匹配第一个字符)。例如,对于字符串"helloWorld"
的匹配,A
将被推断为"h"
,B
将被推断为后面的"elloWorld"
。推断完成后,我们用
${Uppercase<A>}${<B>}
将首字母小写的源串拼接回来,即完成了字符串字面量类型首字母大写的转换。
PascalCasedProps<T>
递归的将接口 T
中的所有键转换为大驼峰
type PascalCasedProps<T> = T extends Function
? T
: T extends Array<infer U>
? Array<PascalCasedProps<U>>
: {
[K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>;
};
复制代码
最后,通过这个 PascalCasedProps
的泛型类型别名,我们就可以实现我们的目的了。但是,它看起来还要更复杂,但其实基本原理还都是一样的,我们还是具体的来解析一下吧:
-
这部分,是判断
T
是否是一个函数,若是,我们需要原样返回类型T
,否则继续进行判断:T extends Function : T 复制代码
-
这部分,则是判断我们传入的
T
类型是否是数组类型T extends Array<infer U> 复制代码
- 如果是的话,我们不能对它直接应用
PascalCasedProps<T>
,(为什么不能,我们稍后进行解释)。- 我们要对数组项的类型做转换,也就是编译器帮助我们推导出来的
U
类型,最终的写法也就是
T extends Array<infer U> ? Array<PascalCasedProps<U>> 复制代码
- 我们要对数组项的类型做转换,也就是编译器帮助我们推导出来的
- 如果是的话,我们不能对它直接应用
-
而如果
T
类型不是数组,比如是一个对象,我们要怎么处理呢?具体的,在这部分:{ [K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>; }; 复制代码
-
首先,在左边的
[K in keyof T as PascalCase<K>]
中,keyof T
即是指类型T
的所有键。举一个例子:// type keys = 'keyA' | 'keyB' type keys = keyof { keyA: string; keyB: number; }; 复制代码
而
K in keyof T
是将一个接口的键,对应到另一个接口上的写法。K
一一对应的代表了T
中每一个键的类型。这里的一一对应是指,在每一次编译器生成键类型时,
K
分别对应不同的键类型,而不是对应他们的复合类型。- 具体来说,对于接口
interface A {a: string; b: string;}
,K
的类型不为a | b
,而在每次推断时分别对应a
,b
- 如果写作
[K: keyof T]
,K
则是复合类型a | b
,自然后面难以正确进行as PascalCase<K>
,T[K]
这样的映射操作了
- 具体来说,对于接口
-
后面的
as PascalCase<K>
则是对每个键的数值映射到对应的小驼峰风格,我们则通过T[K]
取出每个键对应的值的类型,并将它递归的转为小驼峰。至此,我们的转换就顺利完成咯。
-
补充内容:为什么对数组和函数特殊处理?
不过,为什么前面需要对数组和函数进行特判处理,而其他值不用呢?可以首先试一下,为什么要对数组进行判断:
type PascalCase<T> = T extends string
? T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
: T;
type PascalCasedProps<T> = T extends Function
? T
: // : T extends Array<infer U>
// ? Array<PascalCasedProps<U>>
{
[K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>;
};
type A = PascalCasedProps<string[]>;
复制代码
如果注释掉判断数组的部分,我们转换后的类型 A
在编辑器中可以看到,变成了:
type A = {
[x: number]: string;
Length: number;
ToString: () => string;
ToLocaleString: () => string;
Pop: () => string | undefined;
Push: (...items: string[]) => number;
Concat: {
(...items: ConcatArray<string>[]): string[];
(...items: (string | ConcatArray<...>)[]): string[];
};
... 15 more ...;
ReduceRight: {
...;
};
}·
复制代码
这是因为 JavasScript 中数组也是一个对象,它的全部内置方法(例如 .Push()
)也被转换为大驼峰类型,而这显然不是我们想要的。
- 而函数在转换后,则变成了一个空 Object
{}
:这应当是函数本身也被认为是对象,且没有任何属性的缘故吧:
// type A = {}
type A = PascalCasedProps<() => {}>;
复制代码
为什么会这样呢?我做了一个小的试验:
let foo = 1;
foo.bar = 2;
console.log(foo); // 1
console.log(foo.bar); // undefined
let bar = () => {};
bar.foo = 3;
console.log(bar); // [Function: bar]
console.log(bar.foo); // 3
复制代码
可以看到,JavaScript 中的函数确实能作为一个对象,为它的属性赋值,而非引用类型则不可以(虽然不会报错,但值并不会保存下来)
既然这样,TypeScript 有没有办法为函数上的属性声明类型呢?也就是声明一个带属性的函数? 答案是可以的,就像这样:
let foo: { (): void; bar?: number } = () => {}; foo.bar = 1; 复制代码
而对于其他任何非引用类型(如 Number
, String
等),经过我的试验,Typescript 由于不认为他们是对象,会直接原样返回他们的类型。
总结
在利用 TypeScript 进行接口风格转换的过程中,我可以说第一次认识到了 TypeScript 4.1 类型系统的强大之处。在 4.1 版本刚刚发布时,网上有许多声音认为 TypeScript 已经越来越复杂,变得和 Java,C++ 一样了,我个人看着 Template Literal Type 也是摸不着头脑,一时并没有想到这个功能的 use case。但没想到,在一次项目的 TypeScript 重构中,我就体验到了这个功能不仅强大,更及具意义。
程序员间有一个经典的说法:程序编写时永恒的两个问题,分别是变量命名和缓存一致性。如今,随着业务变得越来越复杂,我们调用的,来自不同服务的接口的命名规范,也变得越来越多样。而作为前端工程师,不论是编写页面还是所谓的 BFF 层,如何高效的完成各类 API 的调用和聚合,在我们的开发工作中也是至关重要的。
TypeScript 中可以说实现了应该是目前编程语言中最为强大的类型系统,掌握好其中的高级特性,我们就可以完成对许多类型的管理和转换,高效地写出严谨对应业务数据形式的代码,提高代码的可知性,尽可能在编译时避免绝大部分的问题。
没有看懂这篇文章?没有关系,type-fest 库中已经提供了
PascalCase
,CamelCase
等方便的工具,可以转换字符串字面量类型在不同命名风格之间的转换。而如果需要将整个接口转换为大驼峰,我也将我的实现提交了一个 PR,若需要直接通过引入该库来使用,可以帮忙催促一下仓库所有者合入。