
我们将实现一个 TS 类型转换:将对象的 key 转成 snake_case,期间会使用到 infer
,通过实际操作来学概念才是最牢固的。
假设我们在某个系统中已经存在 camelCase 的 key,类型如下:
ts
type IOpenSourceModel = {
id: number;
description: string;
status: number;
gmtModified: string;
chatShell: 'foo' | 'bar';
highPerformance: boolean;
modelName: string;
}
但是另一个服务使用 python 写的,返回值改成了 snake_case
,我们当然可以手动改:
ts
type IOpenSourceModel = {
id: number;
description: string;
status: number;
gmt_modified: string;
chat_shell: 'foo' | 'bar';
high_performance: boolean;
model_name: string;
}
题外话如果手动改也要充分利用已有类型,所以我们应该这样写:
ts
type IOpenSourceModel = {
id: number;
description: string;
status: number;
gmt_modified: IOpenSourceModel['gmt_mogmtModifieddified'];
chat_shell: IOpenSourceModel['chatShell'];
high_performance: IOpenSourceModel['highPerformance'];
model_name: IOpenSourceModel['modelName'];
}
但这仅仅是部分复用类型,如果想完全复用又该如何做呢?
如果有一个 TS 类型工具,自动将 key 转成 snake_case
就好了:
ts
type ISnakeCasedModel = KeyToSnakeCase<IOpenSourceModel>
实现 KeyToSnakeCase
复杂问题简单化,我们可以拆分成更简单的两步:
- 第一步,将字符串类型转成
snake_case
。 - 第二步,递归遍历
key
做转换。
关键在第一步。
一、字符串转 snake_case
我们先将预期想要达到的结果写出来,即 TDD(Test Driven Development):
ts
type ToSnakeCase<T extends string> = never // TODO
type t1 = ToSnakeCase<'name'>; // name -> name
type t2 = ToSnakeCase<'modelName'>; // modelName -> model_name
type t3 = ToSnakeCase<'model_name'>; // model_name -> model_name
type t4 = ToSnakeCase<'thisIsATest'>; // thisIsATest => this_is_a_test
type t5 = ToSnakeCase<'aaaa'>; // aaaa
type t6 = ToSnakeCase<'aAAA'>; // a_a_a_a
然后实现 ToSnakeCase
,大家思考一下......
如果用 JS 的话,我们可能会使用正则匹配替换,或者干脆使用 lodash/snakeCase,但是 TS 类型就没法这么干。
不过 TS 类型系统有类似正则表达式的 Template Literal Type 和 infer,前者用来遍历或者说匹配,后者用来在条件语句中赋值变量以便下次操作。
TypeScript 中的
infer
关键字是一个强大的工具,用于在条件类型中声明一个可以动态捕获类型的类型变量。它允许开发者以更具表达力且类型安全的方式提取和操作类型。
思路大概是:逐个遍历字符,如果发现是大写,则在其前面增加 _
并且将大写改成小写。目前我们不必考虑单词头部出现大写情况。
经过一番折腾和测试后代码写完了:
ts
type ToSnakeCase<T extends string> = T extends `${infer F}${infer Rest}`
? IsUppercase<F> extends true
? `_${Lowercase<F>}${ToSnakeCase<Rest>}`
: `${F}${ToSnakeCase<Rest>}`
: T;
F
表示第一个字符,Rest
表示剩余字符(取名字很重要的,不要取T、K、V、P
这种单字符变量,尤其是在复杂表达式中)- 判断
F
是否大写(IsUppercase
干的事情我们先不管,将其当做黑盒或函数理解,对了!类型工具其实就是输入类型 → 输出新类型的『函数』) - 如果
F
是大写,前面加上_
且将自身小写(Lowercase
是 TS 内置类型工具)然后递归Rest
。 - 如果是小写,拼接到结果中继续递归
Rest
- 当递归到尽头只有一个字符则直接返回
T
(即第一个判断的 else 分支)
infer F infer Rest?} B -->|否| C[返回 T 本身] B -->|是| D[调用 IsUppercase] D --> E{大写字母?} E -->|是| F[_ + Lowercase F: 重要处理步骤!] E -->|否| G[F 不变] F --> H[递归处理 ToSnakeCase Rest] G --> H H --> I[返回最终蛇形命名字符串] C --> J[结束] I --> J
可以看出 TS 类型工具限制我们只能使用有限的分支结构 extends ? :
和递归的遍历方式,有点『带着镣铐跳舞』的感觉。
接下来实现更容易的部分 IsUppercase
:
- 先排除特殊字符
_
,否则会出现__
的情况 - 然后让自己和自己的大写比较,如果相等则认为是大写
- 否则是小写
ts
type IsUppercase<S extends string> = S extends '_'
? false
: S extends Uppercase<S>
? true
: false;
我们将上述类型转换用 JS 实现一遍方便大家理解:
ts
function toSnakeCase(t: string | string[]): string {
const [first, ...rest] = t
return t.length !== 0
? isUpperCase(first)
? `_${first.toLowerCase()}${toSnakeCase(rest)}`
: `${first}${toSnakeCase(rest)}`
: ''
}
function isUpperCase(t: string): boolean {
return t !== '_' && t.toUpperCase() === t
}
测试下:
ts
console.log('name ->', toSnakeCase('name')); // name -> name
console.log('modelName ->', toSnakeCase('modelName')); // modelName -> model_name
console.log('model_name ->', toSnakeCase('model_name')); // model_name -> model_name
console.log('thisIsATest ->', toSnakeCase('thisIsATest')); // thisIsATest => this_is_a_test
console.log('aaaa ->', toSnakeCase('aaaa')); // aaaa
console.log('aAAA ->', toSnakeCase('aAAA')); // a_a_a_a
最后我们考虑首字母是大写情况,我们之前的写法会导致出现前缀 _
问题:
ts
type t4 = ToSnakeCase<'AAAA'>; // _a_a_a_a
很简单还是通过 infer 匹配去除头部多余的 _
,为了可读性,还是封装成『函数』把,函数式编程思维在哪都适用!
ts
type TrimStartingUnderscores<T extends string> =
T extends `_${infer Rest}` ? TrimStartingUnderscores<Rest> : T;
这里我们同样用到了递归,因为可能头部存在多个下划线,当然在本文情况下不会出现,故只需剔除第一个 _
即可:
ts
type TrimStartingOneUnderscore<T extends string> =
T extends `_${infer Rest}` ? Rest : T;
这样我们最终版的 ToSnakeCase
就『出道』了!
ts
type ToSnakeCaseCore<T extends string> = T extends `${infer F}${infer Rest}`
? IsUppercase<F> extends true
? `_${Lowercase<F>}${ToSnakeCaseCore<Rest>}`
: `${F}${ToSnakeCaseCore<Rest>}`
: T;
type ToSnakeCase<T extends string> = TrimStartingUnderscores<ToSnakeCaseCore<T>>;
有人会说如果结尾出现 _
怎么办?很简单一样通过 infer 和递归去掉即可
ts
type TrimEndingUnderscores<T extends string> =
T extends `${infer Rest}_` ? TrimEndingUnderscores<Rest> : T
测试:
ts
// AAAA___
type t13 = TrimEndingUnderscores<'___AAAA___'>;
组合起来:
ts
// AAAA
type t14 = TrimStartingUnderscores<TrimEndingUnderscores<'___AAAA___'>>;
二、遍历对象做转换
JS 中遍历对象有很多方法,但是对 TS 的 Record 类型遍历只有一种方法,倒也省事了。
很简单,用 K in keyof T
当做 key,T[K]
当做 value 即可遍历:
ts
type Mapper<T> = {
[K in keyof T]: T[K]
};
上述方法只是原封不动并未做任何转换:
ts
// { a: string; b: number[] }
type r1 = Mapper<{ a: string; b: number[] }>;
// string[]
type r3 = Mapper<string[]>;
针对本文我们很容易写出下面将对象 key 转 snake_case
的写法:
ts
type KeyToSnakeCase<T> = {
[ToSnakeCase<K in keyof T>]: T[K]
};
但是 TS 语法过不去。我们得用 as
语法将捕获的类型 K
进一步重(chóng)处理。
ts
type KeyToSnakeCase<T> = {
[K in keyof T as ToSnakeCase<K>]: T[K]
};
距离最后一步就剩一个挑战了:
ts
Type 'K' does not satisfy the constraint 'string'.
Type 'keyof T' is not assignable to type 'string'.
Type 'string | number | symbol' is not assignable to type 'string'. >
Type 'number' is not assignable to type 'string'.ts(2344)
因为 keyof T
是 string | number | symbol
,而 ToSnakeCase 仅接受字符串,即使你将 T
限制为 T extends Record<string, any>
,其依然是三种类型的 union。
怎么办?K & string
交集大法,string | number | symbol & string
→ string
:
故变成:
ts
type KeyToSnakeCase<T extends Record<string, unknown>> = {
[K in keyof T as ToSnakeCase<K & string>]: T[K]
};
我们试一试:
ts
type IOpenSourceModel = {
id: number;
description: string;
status: number;
gmtModified: string;
chatShell: 'foo' | 'bar';
highPerformance: boolean;
modelName: string;
}
type ISnakeCasedModel = KeyToSnakeCase<IOpenSourceModel>;
输出:
ts
type ISnakeCasedModel = {
id: number;
description: string;
status: number;
gmt_modified: string;
chat_shell: "foo" | "bar";
high_performance: boolean;
model_name: string;
}
完美 🎉!
如果要让嵌套对象内的 key 也变成 snake_case
呢?很简单针对嵌套对象继续递归:
ts
type KeyToSnakeCase<T extends Record<string, unknown>> = {
[K in keyof T as ToSnakeCase<K & string>]: T[K] extends Record<string, unknown>
? KeyToSnakeCase<T[K]>
: T[K];
};
也就是加了一段 T[K] extends Record<string, unknown> ? KeyToSnakeCase<T[K]> : T[K]
:如果是对象则继续 KeyToSnakeCase
。
测试下:
ts
type INestedOpenSourceModel = {
id: number;
description: string;
status: number;
gmtModified: string;
fooBar: {
chatShell: 'foo' | 'bar';
bar: {
highPerformance: boolean;
modelName: string;
}
};
};
type ISnakeCasedModel2 = KeyToSnakeCase<INestedOpenSourceModel>;
光标 hover ISnakeCasedModel2
:
ts
type ISnakeCasedModel2 = {
id: number;
description: string;
status: number;
gmt_modified: string;
foo_bar: KeyToSnakeCase<{
chatShell: "foo" | "bar";
bar: {
highPerformance: boolean;
modelName: string;
};
}>;
}
以及:
ts
type ChatShell = ISnakeCasedModel2['foo_bar']['chat_shell']
type HighPerformance = ISnakeCasedModel2['foo_bar']['bar']['high_performance']
type ModelName = ISnakeCasedModel2['foo_bar']['bar']['model_name']
都说明我们成功将嵌套对象的 key 也给转成了 snake_case
。
但是还有一丢丢美观度上面的问题光标 hover ISnakeCasedModel2
并不能完整展现所有 snake_case key。会展示成:
ts
foo_bar: KeyToSnakeCase<{
chatShell: "foo" | "bar";
bar: {
highPerformance: boolean;
modelName: string;
};
}>;
还能优化吗?可以使用 Expand
:
ts
// https://github.com/type-challenges/type-challenges/issues/37240
type Expand<T> = T extends infer O
? { [K in keyof O]: Expand<O[K]> }
: never
type E1 = Expand<ISnakeCasedModel3>
现在在用光标 hover 下,是否看到了展开后的所有 key,是否特别直观了?
ts
type E1 = {
id: number;
description: string;
status: number;
gmt_modified: string;
foo_bar: {
chat_shell: "foo" | "bar";
bar: {
high_performance: boolean;
model_name: string;
};
};
}
注意下面写法无效
type Expand<T> = { [K in keyof T]: Expand<T[K]> }
这是为什么?
第一种写法有效的原因是 条件类型触发了取值导致类型展开:
ts
type Expand<T> = T extends infer O // 这里创建了新的类型实例
? { [K in keyof O]: Expand<O[K]> }
: never
而第二种写法直接进行映射。
- 直接对
T
进行映射,TypeScript 会保持映射类型的"惰性求值" - 递归调用时,
T[K]
仍然是原始的类型引用 - 智能提示显示时,TypeScript 不会深度展开这种结构
还有一种写法也行:
ts
type Expand<T> = T extends object
? { [K in keyof T]: Expand<T[K]> }
: T;
两种可行写法的共同点都具备 extends 即『条件类型』
条件类型的求值机制
当 TypeScript 遇到条件类型 T extends object ? A : B
时:
- 它必须先判断条件是否成立
- 这个判断过程需要对
T
进行求值 - 一旦进入某个分支,该分支内的类型表达式会被进一步求值
对比三种写法
ts
// 写法A:直接映射(无效)
type ExpandA<T> = { [K in keyof T]: ExpandA<T[K]> }
// ❌ 没有触发点,保持惰性
// 写法B:条件类型 + infer(有效)
type ExpandB<T> = T extends infer O
? { [K in keyof O]: ExpandB<O[K]> }
: never
// ✅ infer 创建实例 + 条件类型触发求值
// 写法C:简单条件类型(有效)
type ExpandC<T> = T extends object
? { [K in keyof T]: ExpandC<T[K]> }
: T
// ✅ 条件类型本身触发求值
为什么条件类型能展开而直接映射不能?
根本原因:TypeScript 的类型系统有不同的求值策略
- 映射类型:设计为"模板",保持引用关系
- 条件类型:设计为"决策",需要实际求值来决定走哪个分支
这种差异是 TypeScript 类型系统有意为之的设计选择,目的是:
- 性能优化:避免不必要的深度求值
- 清晰度:在工具类型和展开显示之间取得平衡
- 可控性:让开发者选择何时需要深度展开
Expand
使用场景与注意事项
最后再念叨一句,虽然 Expand
能让类型提示更清晰,但在使用时需要注意:
- 性能:对于非常深或非常宽(属性极多)的对象类型,深度递归可能会增加 TypeScript 编译器的负担,影响类型检查速度。
- 必要性 :并非所有情况都需要完全展开。有时保留一层工具类型(如我们的
KeyToSnakeCase
)反而能让焦点更突出。VSCode 等编辑器也支持点击展开嵌套的类型。
总结
本文我们不仅学会了 infer
,更重要是学会了将复杂问题拆解成一个个简单任务,同时体会到了函数组合的魅力。