TypeScript 类型体操实战:从看不懂到手撕 5 道高频面试题

12 阅读1分钟

上周面试一个高级前端岗,面试官甩过来一道题:「实现一个 DeepReadonly<T>,要求递归把所有嵌套属性变成只读。」

我盯着屏幕愣了十秒钟。

平时写业务代码 TypeScript 用得挺溜,interfacegenericunion type 随手就来。但一碰到这种类型编程——社区管它叫「类型体操」——脑子就转不过来。面完之后花了整整一个周末刷 type-challenges,从 easy 刷到 medium,才把核心套路摸清楚。

今天把我觉得最值得刷的 5 道题整理出来,每道都带完整思路和踩坑记录。不是那种贴个答案就跑的文章,我会尽量把「为什么这么写」讲明白。

先说结论:类型体操的核心就这几板斧

刷了几十道题之后,发现来来回回就这些工具在组合:

- 条件类型T extends U ? X : Y(类型世界的 if-else) - infer 关键字:在条件类型里「捕获」一个类型变量 - 映射类型{ [K in keyof T]: ... }(遍历对象类型的每个 key) - 递归:类型可以引用自身,处理嵌套结构 - 模板字面量类型`${A}${B}` 操作字符串类型 - 元组遍历:用 [infer First, ...infer Rest] 解构元组

搞懂这几个,medium 难度基本都能推出来。

graph TD
 A[类型体操核心工具] --> B[条件类型 extends]
 A --> C[infer 捕获]
 A --> D[映射类型 keyof + in]
 A --> E[递归引用]
 A --> F[模板字面量]
 A --> G[元组解构]
 B --> H[分发特性 Distributive]
 C --> I[函数参数/返回值提取]
 D --> J[属性修饰 readonly/optional]
 E --> K[嵌套对象/数组处理]
 F --> L[字符串变换]
 G --> M[数组类型操作]

第一题:实现 MyPick<T, K>(Easy)

type-challenges 的第一道题,看似简单,是理解映射类型的基础。

目标:实现 TS 内置的 Pick,从对象类型 T 中选出指定属性 K

interface Todo {
 title: string
 description: string
 completed: boolean
}

type TodoPreview = MyPick<Todo, 'title' | 'completed'>
// 期望结果:{ title: string; completed: boolean }

思路:遍历 K 里的每个 key,从 T 中取对应的值类型。

type MyPick<T, K extends keyof T> = {
 [P in K]: T[P]
}

K extends keyof T 是约束——你不能 Pick 一个 T 上不存在的属性。P in K 就是遍历联合类型 K 的每一个成员。

踩坑:一开始写成了 K extends string,结果传入不存在的 key 也不报错。一定要约束成 keyof T,这才是类型安全的关键。

第二题:实现 MyReturnType<T>(Medium)

面试高频题,考的是 infer 的用法。

目标:提取函数类型的返回值类型。

const fn = (v: boolean) => {
 if (v) return 1
 else return 2
}

type Result = MyReturnType<typeof fn> // 期望:1 | 2

思路:用条件类型 + infer 从函数签名里「抠出」返回值。

type MyReturnType<T extends (...args: any[]) => any> = 
 T extends (...args: any[]) => infer R ? R : never

拆解一下: 1. T extends (...args: any[]) => any 先约束 T 必须是函数 2. T extends (...args: any[]) => infer R 再匹配一次,用 infer R 声明一个「待推断」的类型变量 R 3. 匹配成功,R 就捕获到了返回值类型;否则返回 never

踩坑infer 只能在 extends 的条件子句里用。第一次写的时候尝试在外面单独声明 infer R,直接报语法错误。这个关键字就是跟条件类型绑定的,离开 extends ? : 它啥也不是。

第三题:实现 DeepReadonly<T>(Medium)

就是面试挂我的那道题。搞定之后发现其实不难,核心是递归。

目标:递归地将对象所有属性(包括嵌套对象)变为只读。

interface Nested {
 a: {
 b: {
 c: string
 }
 d: number[]
 }
 e: boolean
}

type Result = DeepReadonly<Nested>
// 期望:所有层级的属性都是 readonly

思路:映射类型 + 递归。对每个属性,如果值是对象就递归处理,否则直接 readonly。

type DeepReadonly<T> = {
 readonly [K in keyof T]: T[K] extends Record<string, unknown>
 ? DeepReadonly<T[K]>
 : T[K]
}

等一下,这个写法有坑。

测试的时候发现数组类型也会被当成对象处理(因为 Array 确实 extends Record<string, unknown>),结果数组变成了一个奇怪的映射类型,把 lengthpush 这些属性全变成 readonly 了。

改进版本:

type DeepReadonly<T> = T extends (...args: any[]) => any
 ? T // 函数类型原样返回
 : T extends object
 ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
 : T // 原始类型原样返回

T extends object 替代 Record 判断,并且优先排除函数类型。实测下来数组也能正确处理——TS 对数组有特殊的映射类型行为,{ readonly [K in keyof number[]]: ... } 会正确生成 readonly number[]

踩坑记录: - Record<string, unknown> 不能正确区分数组和普通对象 - 函数类型也是 object,不先排除会把函数签名拆掉 - 调试类型体操的时候,多用 hover 看推断结果,VS Code 里鼠标悬停在类型别名上就能看到展开后的类型

第四题:实现 TrimLeft<S>(Medium)

模板字面量类型的入门题,字符串处理类的基石。

目标:去掉字符串类型左侧的空白字符。

type Result = TrimLeft<' hello '> // 期望:'hello '

思路:递归匹配,每次砍掉第一个空白字符。

type Space = ' ' | '\n' | '\t'

type TrimLeft<S extends string> = 
 S extends `${Space}${infer Rest}` 
 ? TrimLeft<Rest> 
 : S

这段代码做的事:看 S 是不是「空白字符 + 剩余部分」的格式,如果是,infer Rest 捕获剩余部分,递归继续砍;如果不是,直接返回 S。

第一次看到模板字面量类型能这么用的时候我是真的震惊了。相当于在类型层面做字符串正则匹配,TS 的类型系统是图灵完备的,信了。

扩展:完整的 Trim<S> 再加一个 TrimRight 就行:

type TrimRight<S extends string> = 
 S extends `${infer Rest}${Space}` 
 ? TrimRight<Rest> 
 : S

type Trim<S extends string> = TrimRight<TrimLeft<S>>

第五题:实现 Flatten<T>(Medium)

数组/元组操作的经典题目。

目标:把嵌套数组类型拍平成一维。

type Result = Flatten<[1, 2, [3, 4], [[[5]]]]> 
// 期望:[1, 2, 3, 4, 5]

思路:元组解构 + 递归。取第一个元素,如果是数组就展开,不是就保留,然后递归处理剩余部分。

type Flatten<T extends any[]> = T extends [infer First, ...infer Rest]
 ? First extends any[]
 ? [...Flatten<First>, ...Flatten<Rest>]
 : [First, ...Flatten<Rest>]
 : []

逻辑拆解: 1. [infer First, ...infer Rest] 把元组拆成「第一个元素」和「剩余元素」 2. First 本身是数组,递归拍平它,再拼上递归拍平后的 Rest 3. First 不是数组,直接放进结果,继续处理 Rest 4. 空数组返回 [],递归终止

踩坑:TS 类型递归有深度限制(大概 1000 层),嵌套太深会报 Type instantiation is excessively deep and possibly infinite。实际业务中极少遇到,刷题偶尔会被卡住。遇到了可以试试「尾递归优化」的写法——TS 4.5+ 对尾递归类型有一定优化,但不是所有场景都生效。

调试类型体操的实用技巧

刷题过程中总结了几个省时间的技巧:

1. 用空映射强制展开类型

有时候 hover 看到的是 DeepReadonly<Nested>,没展开。加一层空映射可以强制 TS 展开显示:

type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never

// 使用
type Check = Expand<DeepReadonly<Nested>>
// hover 就能看到完全展开的结构

2. 用 @ts-expect-error 做断言测试

type Assert<T extends true> = T
type IsEqual<A, B> = 
 (<T>() => T extends A ? 1 : 2) extends 
 (<T>() => T extends B ? 1 : 2) ? true : false

// 测试你的实现
type Case1 = Assert<IsEqual<MyPick<Todo, 'title'>, { title: string }>>
// 如果类型不对,这行会报错

3. TS Playground 是最好的练习场

直接用 TypeScript Playground 在线调试,不用搭本地环境。右侧面板开 .D.TS 可以看到类型推断结果。

学习路径建议

刷了一圈下来,比较合理的路径大概是这样:

1. 先搞懂 5 个基础工具(就是文章开头那几个),每个写 2-3 个小例子 2. type-challenges 的 easy 全刷,大概 13 道,一下午能搞定 3. medium 挑着刷,优先刷面试高频的:DeepReadonlyFlattenTrimLeftIsUnionTupleToUnion 4. hard 看心情——实际业务中几乎用不到,但对理解类型系统的能力边界有帮助

我自己的感受是:类型体操训练的是你对类型系统的直觉,不是炫技用的。刷过之后再去读开源库的类型定义,比如 Vue 3 的模板类型推断、tRPC 的端到端类型安全,就不再觉得是天书了。

小结

类型体操说白了就是用 extendsinferin、递归做模式匹配。一旦接受了「TS 类型系统本身就是一门函数式编程语言」这个设定,很多写法就顺理成章了。

那道 DeepReadonly 后来我回去秒了,不过那个 offer 已经没了。花了一个 offer 的代价学会了类型体操,性价比不好说,但技能树确实点上了 🤷‍♂️

有兴趣的可以直接去 type-challenges 开刷,仓库里有在线 playground 链接,零配置直接开写。