面试常见问题 TS 的 infer 你会用吗?对象如何转 snake_case

我们将实现一个 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

复杂问题简单化,我们可以拆分成更简单的两步:

  1. 第一步,将字符串类型转成 snake_case
  2. 第二步,递归遍历 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 分支)
graph TD A[开始 ToSnakeCase T] --> B{T 是否匹配
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;
graph TD subgraph IsUppercase 子流程 K[开始: IsUppercase S] --> L{S 是 '_' ?} L -->|是| M[返回 false] L -->|否| N{S == Uppercase S ?} N -->|是| O[返回 true] N -->|否| P[返回 false] M --> Q[结束] O --> Q P --> Q end

我们将上述类型转换用 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 Tstring | number | symbol,而 ToSnakeCase 仅接受字符串,即使你将 T 限制为 T extends Record<string, any>,其依然是三种类型的 union。

怎么办?K & string 交集大法,string | number | symbol & stringstring

故变成:

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

而第二种写法直接进行映射。

  1. 直接对 T 进行映射,TypeScript 会保持映射类型的"惰性求值"
  2. 递归调用时,T[K] 仍然是原始的类型引用
  3. 智能提示显示时,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 的类型系统有不同的求值策略

  1. 映射类型:设计为"模板",保持引用关系
  2. 条件类型:设计为"决策",需要实际求值来决定走哪个分支

这种差异是 TypeScript 类型系统有意为之的设计选择,目的是:

  • 性能优化:避免不必要的深度求值
  • 清晰度:在工具类型和展开显示之间取得平衡
  • 可控性:让开发者选择何时需要深度展开
Expand 使用场景与注意事项

最后再念叨一句,虽然 Expand 能让类型提示更清晰,但在使用时需要注意:

  • 性能:对于非常深或非常宽(属性极多)的对象类型,深度递归可能会增加 TypeScript 编译器的负担,影响类型检查速度。
  • 必要性 :并非所有情况都需要完全展开。有时保留一层工具类型(如我们的 KeyToSnakeCase)反而能让焦点更突出。VSCode 等编辑器也支持点击展开嵌套的类型。

总结

本文我们不仅学会了 infer,更重要是学会了将复杂问题拆解成一个个简单任务,同时体会到了函数组合的魅力。

参考

相关推荐
guangzan5 小时前
解决 Semi Design Upload 组件实现自定义压缩,上传文件后无法触发 onChange
typescript·semi design
AI智能研究院13 小时前
TypeScript 快速入门与环境搭建
前端·javascript·typescript
liangshanbo12151 天前
React + TypeScript 企业级编码规范指南
ubuntu·react.js·typescript
duandashuaige2 天前
解决用electron打包Vue工程(Vite)报错electron : Failed to load URL : xxx... with error : ERR _CONNECTION_REFUSED
javascript·typescript·electron·npm·vue·html
Damon小智3 天前
仓颉 Markdown 解析库在 HarmonyOS 应用中的实践
华为·typescript·harmonyos·markdown·三方库
熊猫钓鱼>_>3 天前
TypeScript前端架构与开发技巧深度解析:从工程化到性能优化的完整实践
前端·javascript·typescript
敲敲敲敲暴你脑袋3 天前
Canvas绘制自定义流动路径
vue.js·typescript·canvas
m0dw3 天前
vue懒加载
前端·javascript·vue.js·typescript
流影ng4 天前
【HarmonyOS】并发线程间的通信
typescript·harmonyos