面试常见问题 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 是否匹配<br> 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,更重要是学会了将复杂问题拆解成一个个简单任务,同时体会到了函数组合的魅力。

参考

相关推荐
妙码生花32 分钟前
从 PHP 到 AI + Golang,程序员自救转型手记(二十六):icon 组件封装
前端·vue.js·typescript
PBitW6 小时前
为什么vite中TS报错,可以继续运行?Webpack不行?💡
前端·typescript
敖行客 Allthinker7 小时前
前端调试实战:DevTools调试工具 + 二分定位Bug法全总结
前端·typescript·chrome devtools
不瘦80斤不改名8 小时前
HalfCart:基于LBS的1\.5km本地拼单系统全栈实践与踩坑复盘
python·docker·typescript·pycharm
濮水大叔1 天前
在大型 Vue 项目中,如何有效管理复杂的状态?“散装版” vs “套装版”
前端·vue.js·typescript
谷哥的小弟2 天前
TypeScript数组类型
javascript·typescript
退休倒计时2 天前
【每日一题】LeetCode 199. 二叉树的右视图 TypeScript
算法·leetcode·typescript
前端之虎陈随易3 天前
编程语言级别的Skill市场,AI Agent 的未来形态
前端·vue.js·人工智能·typescript·node.js
后台开发者Ethan3 天前
类型分类、联合类型、交叉类型
typescript
鹰影473 天前
一款AI笔记助手和远程同步的markdown笔记idea-note
人工智能·笔记·rust·typescript·react