题目
type challenge 是一个 TypeScript 类型体操姿势合集。里面有「简单」「中等」「困难」「地狱」四个等级的题目。今天我们来完成其中的一道困难级别题目:实现 Camelize 函数。
题目如下:
比如某个对象的属性都是下划线连接的,传入我们手写的 Camelize 类型,最终结果会把下划线转换成驼峰连接。

为了了解 camelize 的实现原理,我们先用 js 自己实现一下。
JS 代码实现 camelize
import { camelCase } from 'lodash';
export const isPlainObjectX = (obj) => Object.prototype.toString.call(obj) === '[object Object]';
function camelize(obj) {
if (Array.isArray(obj)) {
return obj.map(item => camelize(item));
} else if (isPlainObjectX(obj)) {
const newObj = Object.create(null);
Object.keys(obj).forEach(key => {
newObj[camelCase(key)] = camelize(obj[key]);
});
return newObj;
}
return obj;
}
理一下逻辑:
-
入参是个对象或数组
-
如果是数组,则对每一项递归进行 camelize
-
如果是对象,将对象的 key 改为 camelCase,并对 value 递归进行 camelize
-
否则,不处理直接返回
可以看到 camelize 的实现依赖 camelCase,camelCase 来自于 lodash。
但 ts 类型里没有 lodash,因此我们也首先用 ts 类型来实现 CamelCase。
TS 实现 CamelCase
该题也是 ts 类型挑战中难度为 Hard 类型的题目。
Test Case
先看看测试用例,心里有个数:
type camelCase1 = CamelCase<'hello_world_with_types'> // expected 'helloWorldWithTypes'
type camelCase2 = CamelCase<'HELLO_WORLD_WITH_TYPES'> // expected 'helloWorldWithTypes'
预备知识
条件类型(extends 关键字)
extends 除了表示从一个类型扩展出另外一个新类型,还能用作条件类型,其写法有点像 JS 中的三元表达式(条件 ? true 表达式 : false 表达式)
SomeType extends OtherType ? TrueType : FalseType;
意为:如果 SomeType 可以分发给 OtherType,那么返回 TrueType,否则返回 FalseType。
比如:
type Example = Dog extends Animal ? number : string;
// number
Dog 可以分发给 Animal,属于 Animal 的子类型,Example 会得到 number 类型
条件类型中的类型推断(infer 关键字)
infer 可以在 extends 的条件语句中推断待推断的类型,它一定是出现在条件类型中的。
比如可以利用 infer 推断某个函数的返回值类型:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
// R 就是函数的返回值类型
利用 infer 推断某个数组每一项的类型:
type GetItem<T> = T extends (infer R)[] ? R : T;
// R 就是数组每一项的类型
它就是对于 extends 后面未知的某个类型进行一个占位 infer R,后续就可以使用推断出来的 R 这个类型。
操作字符类型
ts 有一些内置的字符操作类型:
- Uppercase<StringType>,把 string 都大写
type Greeting = "Hello, world"
type ShoutyGreeting = Uppercase<Greeting> // "HELLO, WORLD"
- Lowercase<StringType>,把 string 都小写
type Greeting = "Hello, world"
type QuietGreeting = Lowercase<Greeting> // "hello, world"
- Capitalize<StringType>,把 string 首字母大写
type LowercaseGreeting = "hello, world";
type Greeting = Capitalize<LowercaseGreeting>; // "Hello, world"
- Uncapitalize<StringType>,把 string 首字母小写
type UppercaseGreeting = "HELLO WORLD";
type UncomfortableGreeting = Uncapitalize<UppercaseGreeting>; // "hELLO WORLD"
除了上面内置类型之外,还可以使用模板字符串
type World = 'world';
type Greeting = `hello ${World}`; // "hello world"
代码实现 CamelCase
- 因为待转换的字符是 snakeCase 下划线连接的,我们可以使用 infer 推断下划线前后的字符 P 和 T,并将 T 的首字母大写。
type CamelCase<S> = S extends `${infer P}_${infer T}`
? `${P}${Capitalize<T>}`
: S
type camelCase = CamelCase<'foo_bar'>
-
但是这样还不够,因为字符串可能是多个下划线连接的
需要递归对下划线后的字符继续调用 camelCase
type CamelCase<S> = S extends `${infer P}_${infer T}`
? `${P}${Capitalize<CamelCase<T>>}`
: S
-
我们只对字符进行了首字母大写的操作,但是如果一开始都是大写字母,该操作没有意义
所以还需要将其余剩余字母转换成小写。
type CamelCase<S extends string> = S extends Lowercase<S>
? S extends `${infer P}_${infer T}`
? `${P}${Capitalize<CamelCase<T>>}`
: S
: CamelCase<Lowercase<S>>
完整代码如下:
type CamelCase<S extends string> = S extends Lowercase<S>
? S extends `${infer P}_${infer T}`
? `${P}${Capitalize<CamelCase<T>>}`
: S
: CamelCase<Lowercase<S>>
TS 实现 Camelize
实现了依赖的 CamelCase,现在可以来实现最终的 Camelize 了。
Test Case
先看看测试用例:
type camelize = Camelize<{
some_prop: string,
prop: {
another_prop: string
},
array: [{
snake_case: string
}]
}>
// expected to be
// {
// someProp: string,
// prop: {
// anotherProp: string
// },
// array: [{
// snakeCase: string
// }]
// }
预备知识
遍历对象
可以使用 keyof 获取某个对象类型 T 的所有 key 的集合,比如:
interface Person {
name: string;
age: number;
}
type attrs = keyof Person;
// attrs 的类型为 "name" | "age" 的联合类型
所以遍历一个对象类型 T,获取它的 key 和 value 类型可以这样写:
type traverse<T extends Object> = {
[P in keyof T]: T[P]
}
P in keyof T 表示 P 是 T 的其中一个 key,P 就是 key 的联合类型,T[P] 表示 value 的联合类型
遍历数组
参考上面操作,P 是 T 的某个索引,T[P] 可以表示对象 value 的联合类型,
数组的索引都是 number,所以可以用 T[number] 来表示数组 value 的联合类型
代码实现
- 依然从最简单的入手,先来处理简单对象的情况,无嵌套,只有一层:
type camelize = Camelize<{
foo_bar: 'foo_bar'
}>
先根据上面遍历对象的方法,得到入参 key 和 value 对应的联合类型
type Camelize<T> = T extends Object
? {
[P in keyof T]: T[P]
}
: T
现在先将 key 转换为 camelCase,调用一开始实现的 camelCase 方法,但是直接将 P in keyof T 这一整部分传入 CameCase 类型会报错
这里需要使用 as 断言,比如断言为 string。
type Camelize<T> = T extends Object
? {
[P in keyof T as string]: T[P]
}
: T
然后再把这个 string 通过 CamelCase 转换一下,这里要联合 extends 一起使用。
type Camelize<T> = T extends Object
? {
[P in keyof T as P extends string ? CamelCase<P> : P]: T[P]
}
: T
结果
- 递归处理对象
处理了 key,我们还需要继续对 T[P] 进行处理,如果 T[P] 是对象就继续递归调用 Camelize,保证嵌套的对象都能正确转换。
type Camelize<T> = T extends Object
? {
[P in keyof T as P extends string ? CamelCase<P> : P]: T[P] extends Object
? Camelize<T[P]>
: T[P]
}
: T
验证下结果
- 处理数组
上面我们只处理了对象,接下来处理数组的场景。
在处理对象时,T[P] 可能是数组,所以 Camelize 的入参除了是对象,还可能是数组,需要在一开始新增判断数组的逻辑
type Camelize<T> = T extends any[]
? // 处理数组
: T extends Object
? {
[P in keyof T as P extends string ? CamelCase<P> : P]: T[P] extends Object
? Camelize<T[P]>
: T[P]
}
: T
接着对数组中每一项都跑一遍 Camelize
type Camelize<T> = T extends any[]
? [Camelize<T[number]>]
: T extends Object
? {
[P in keyof T as P extends string ? CamelCase<P> : P]: T[P] extends Object
? Camelize<T[P]>
: T[P]
}
: T
完整代码
type Camelize<T> = T extends any[]
? [Camelize<T[number]>]
: T extends Object
? {
[P in keyof T as P extends string ? CamelCase<P> : P]: T[P] extends Object
? Camelize<T[P]>
: T[P]
}
: T
所有代码
使用 ts 实现 Camelize 的所有代码如下:
type CamelCase<S extends string> = S extends Lowercase<S>
? S extends `${infer P}_${infer T}`
? `${P}${Capitalize<CamelCase<T>>}`
: S
: CamelCase<Lowercase<S>>
type Camelize<T extends Object | any[]> = T extends any[]
? [Camelize<T[number]>]
: T extends Object
? {
[P in keyof T as P extends string ? CamelCase<P> : P]: T[P] extends Object
? Camelize<T[P]>
: T[P]
}
: T
相信掌握了上面的知识以及完成本次实战的同学,大家完成其它的 ts 挑战也是分分钟的事。