上周面试一个高级前端岗,面试官甩过来一道题:「实现一个 DeepReadonly<T>,要求递归把所有嵌套属性变成只读。」
我盯着屏幕愣了十秒钟。
平时写业务代码 TypeScript 用得挺溜,interface、generic、union 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>),结果数组变成了一个奇怪的映射类型,把 length、push 这些属性全变成 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 挑着刷,优先刷面试高频的:DeepReadonly、Flatten、TrimLeft、IsUnion、TupleToUnion
4. hard 看心情——实际业务中几乎用不到,但对理解类型系统的能力边界有帮助
我自己的感受是:类型体操训练的是你对类型系统的直觉,不是炫技用的。刷过之后再去读开源库的类型定义,比如 Vue 3 的模板类型推断、tRPC 的端到端类型安全,就不再觉得是天书了。
小结
类型体操说白了就是用 extends、infer、in、递归做模式匹配。一旦接受了「TS 类型系统本身就是一门函数式编程语言」这个设定,很多写法就顺理成章了。
那道 DeepReadonly 后来我回去秒了,不过那个 offer 已经没了。花了一个 offer 的代价学会了类型体操,性价比不好说,但技能树确实点上了 🤷♂️
有兴趣的可以直接去 type-challenges 开刷,仓库里有在线 playground 链接,零配置直接开写。