将小驼峰接口类型递归的转成大驼峰:TypeScript 高级类型与 4.1 字符串模板类型实战

4,232 阅读7分钟

在 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 } 类型,故 Atrue

    type A = { a: string; b: number } extends { b: number } ? true : false;
    // type A = false
    
  • { } 未包含 { b:number } 类型,故 Bfalse

    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 的结构,自动推出 AB 对应的部分。

    更具体的,我们可以来看两个例子,说明 infer 的作用:

    1. 提取函数第一个参数

      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 = numberT 即可实现后面的函数,这样,我们就达到了提取函数第一个参数类型的目的。

    2. 去除字符串字面量类型尾部 _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,若需要直接通过引入该库来使用,可以帮忙催促一下仓库所有者合入。