上周组里来了个实习生,Review 他代码的时候发现满屏 as any,我说兄弟你这写的是 AnyScript 啊。他很委屈:「学长,TS 类型太难了,稍微复杂一点就不会写了。」
说实话我特别理解这种感受,因为我自己也是从 any 大法一路走过来的。直到有一天线上出了个 bug,排查了半天发现就是类型不对导致的——一个本该是 string[] 的字段被当成 string 用了,as any 完美绕过了编译检查。那次之后我痛定思痛,花了两周专门练类型体操,现在虽然不敢说多厉害,但至少不用写 any 了。
今天把我觉得最有实战价值的 5 道类型体操题整理出来,不是那种纯炫技的变态题,而是你日常写业务代码真的会用到的模式。
先说结论
| 题目 | 难度 | 实战价值 | 核心知识点 |
|---|---|---|---|
| DeepReadonly | ⭐⭐ | 高 | 递归类型、条件类型 |
| PathKeys | ⭐⭐⭐ | 高 | 模板字面量、递归 |
| PickByType | ⭐⭐ | 中 | 映射类型、条件过滤 |
| TupleToUnion | ⭐ | 中 | indexed access types |
| StrictOmit | ⭐⭐ | 高 | 泛型约束、内置工具类型改造 |
掌握这 5 个模式,日常业务中 90% 的复杂类型都能搞定。
为什么要练类型体操?
我知道很多人觉得类型体操是「面试八股」,工作中用不到。一年前我也这么想。
但后来我发现,当你在封装组件库、写工具函数、设计 API 响应类型的时候,类型体操不是可选项,而是刚需。举几个真实场景:
- 封一个
deepClone函数,返回值类型得是深度只读的吧?不然 clone 出来的对象被人改了你都不知道 - 写一个表单组件,
onChange的回调参数类型得根据字段路径自动推导吧?总不能全写any - 后端返回的嵌套 JSON 对象,你想根据
a.b.c这样的路径取值,返回类型得对吧?
这些都不是「炫技」,而是实打实要写的东西。
好了废话不多说,直接上题。
第一题:DeepReadonly —— 递归的入门课
需求
Readonly<T> 大家都用过,但它只管一层。我要一个深度只读类型,嵌套对象也得是只读的。
type Nested = {
a: {
b: {
c: string
}
d: number[]
}
e: boolean
}
type Result = DeepReadonly<Nested>
// 期望:a.b.c 也是 readonly 的
解题
先写个错误版本,这是大多数人第一次的写法:
// ❌ 错误版本:只处理了一层
type DeepReadonly<T> = {
readonly [K in keyof T]: Readonly<T[K]>
}
这个只 readonly 了两层,再深就不管了。正确做法是递归:
// ✅ 正确版本
type DeepReadonly<T> = T extends object
? T extends Function
? T // 函数类型不处理,直接返回
: { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
关键点在 T extends Function 这个判断。因为函数也是 object,如果不排除掉,函数类型会被展开成一坨看不懂的东西。这个坑我踩过,IDE 提示直接炸了。
验证一下:
type Test = DeepReadonly<Nested>
const obj: Test = {
a: { b: { c: 'hello' }, d: [1, 2, 3] },
e: true
}
obj.a.b.c = 'world' // ✅ 报错:Cannot assign to 'c' because it is a read-only property
obj.a.d.push(4) // ⚠️ 注意:这个不会报错!
等等,数组的 push 没报错?对,因为 readonly number[] 和 number[] 在 TS 里是不同的类型,但我们的 DeepReadonly 把数组变成了 { readonly [K in keyof number[]]: ... },这个结构并没有去掉 push 方法。
如果你想让数组也变成真正的 readonly,需要加个判断:
type DeepReadonly<T> = T extends object
? T extends Function
? T
: T extends readonly any[] // 处理数组/元组
? readonly [...{ [K in keyof T]: DeepReadonly<T[K]> }]
: { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T
说实话这已经开始有点绕了,但逻辑是清晰的:先判断是不是 object → 排除函数 → 处理数组 → 处理普通对象。类型体操本质上就是这种分支判断 + 递归的套路。
第二题:PickByType —— 映射类型的过滤技巧
需求
从对象类型中挑出值为指定类型的属性。比如只要 string 类型的字段:
type Model = {
name: string
age: number
active: boolean
email: string
}
type StringFields = PickByType<Model, string>
// 期望:{ name: string; email: string }
解题
这题的关键是「如何在映射类型里过滤 key」。很多人不知道,TS 的映射类型里,如果把某个 key 映射到 never,它就会被自动移除。
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
就这么短。核心是 as T[K] extends U ? K : never 这个 key remapping 语法,TS 4.1 引入的。用 as 对 key 做二次映射,不满足条件的直接映射到 never 就扔掉了。
这个模式在业务里非常好用。比如我之前写一个表单工具库,需要从 schema 对象里自动提取所有 string 类型的字段来做文本校验,用的就是这个套路:
// 实际业务中的用法
type FormSchema = {
username: string
age: number
bio: string
avatar: File
score: number
}
type TextFields = PickByType<FormSchema, string>
// { username: string; bio: string }
// 然后对这些字段统一加 maxLength 校验
type TextValidation = {
[K in keyof TextFields]: { maxLength: number }
}
第三题:TupleToUnion —— 看似简单但藏着知识点
需求
把元组类型转成联合类型:
type Tuple = ['a', 'b', 'c']
type Result = TupleToUnion<Tuple> // 'a' | 'b' | 'c'
解题
这题其实一行就能搞定,但搞定的方式非常值得理解:
type TupleToUnion<T extends readonly any[]> = T[number]
没了。T[number] 就是用 number 作为索引去访问元组类型,TS 会自动把所有位置的类型取出来组成联合类型。
这个 T[number] 的用法叫 Indexed Access Types,是 TS 类型体操里最基础也最容易被忽视的特性之一。
你可能会问,这有什么实战价值?太多了:
// 场景:路由表的路径自动提取
const routes = ['/home', '/about', '/user/profile', '/settings'] as const
type RoutePath = (typeof routes)[number]
// '/home' | '/about' | '/user/profile' | '/settings'
// 现在 navigate 函数的参数就有类型提示了
function navigate(path: RoutePath) { /* ... */ }
navigate('/home') // ✅
navigate('/login') // ❌ 报错
as const + T[number] 这个组合拳在日常开发里用得太多了,配置项枚举、路由表、权限列表……凡是有一个固定数组想提取联合类型的场景都用得上。
第四题:PathKeys —— 真正的硬骨头
需求
给定一个嵌套对象类型,自动生成所有合法的访问路径:
type Obj = {
a: {
b: string
c: {
d: number
}
}
e: boolean
}
type Paths = PathKeys<Obj>
// 'a' | 'a.b' | 'a.c' | 'a.c.d' | 'e'
这个在写 lodash.get 类型安全版、或者表单库的字段路径时非常有用。
解题
这题我当初写了两个小时,废了好几个版本。先看最终结果:
type PathKeys<T, Prefix extends string = ''> = T extends object
? T extends Function
? never
: {
[K in keyof T & string]:
| `${Prefix}${K}`
| PathKeys<T[K], `${Prefix}${K}.`>
}[keyof T & string]
: never
拆解一下:
T extends object—— 只处理对象类型keyof T & string—— 确保 key 是 string(排除 symbol 和 number)`${Prefix}${K}`—— 当前层级的路径(比如'a'、'a.b')PathKeys<T[K], `${Prefix}${K}.`>—— 递归下一层,把当前路径作为前缀传下去[keyof T & string]—— 最后用 indexed access 把映射类型展开成联合类型
验证:
type Test = PathKeys<Obj>
// 'a' | 'a.b' | 'a.c' | 'a.c.d' | 'e' ✅
踩坑
我第一版没加 T extends Function 的判断,结果遇到 Date 类型的字段直接爆炸——Date 也是 object,递归进去就开始展开 getTime、toISOString 这些方法的 key,生成一堆 'createdAt.getTime' 这种莫名其妙的路径。
还有一个坑是递归深度。如果你的对象嵌套超过 5-6 层,TS 会报 Type instantiation is excessively deep and possibly infinite 错误。解决方案是加个深度限制:
type PathKeys<T, Prefix extends string = '', Depth extends any[] = []> =
Depth['length'] extends 5 // 最多递归 5 层
? never
: T extends object
? T extends Function
? never
: {
[K in keyof T & string]:
| `${Prefix}${K}`
| PathKeys<T[K], `${Prefix}${K}.`, [...Depth, any]>
}[keyof T & string]
: never
用 Depth extends any[] 来计数,每递归一层就往元组里塞一个元素,到 5 就停。这个「用元组长度做计数器」的技巧在类型体操里非常常见,因为 TS 的类型系统没有数字运算,只能靠这种方式模拟。
第五题:StrictOmit —— 改造内置工具类型
需求
TS 内置的 Omit 有个很坑的地方:它不会检查你传入的 key 是否存在。
type User = { name: string; age: number }
type T1 = Omit<User, 'name'> // ✅ { age: number }
type T2 = Omit<User, 'typoName'> // 😱 不报错!返回 { name: string; age: number }
你手滑写了个不存在的 key,TS 一声不吭,Omit 返回了原类型,bug 就这么埋下了。我就被这个坑过一次,排查了大半天。
解题
写一个严格版的 Omit,key 不存在直接报错:
type StrictOmit<T, K extends keyof T> = Omit<T, K>
就这?就这。关键在 K extends keyof T 这个约束。内置 Omit 的定义是 Omit<T, K extends string | number | symbol>,它只要求 K 是合法的 key 类型,不要求 K 必须是 T 的 key。
加上 extends keyof T 之后:
type T1 = StrictOmit<User, 'name'> // ✅ { age: number }
type T2 = StrictOmit<User, 'typoName'> // ❌ Type '"typoName"' does not satisfy the constraint '"name" | "age"'
这个改造虽然简单,但我建议所有项目都在 utils/types.ts 里加上这个。再顺带写几个类似的:
// 严格版 Pick:key 不存在报错(其实内置 Pick 本身就是严格的,但为了一致性也放这)
type StrictPick<T, K extends keyof T> = Pick<T, K>
// 必选部分字段
type RequiredPick<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
// 可选部分字段
type PartialPick<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
最后两个在写组件 Props 的时候特别好用。比如一个 Modal 组件,visible 和 onClose 是必选的,其他都可选:
type ModalProps = PartialPick<FullModalProps, 'title' | 'width' | 'footer'>
比手动写一堆 ? 优雅多了。
踩坑记录汇总
练类型体操这段时间踩的坑,统一记一下:
-
递归类型一定要加终止条件。不然 TS 要么报
excessively deep,要么直接卡死 IDE。特别是用 VSCode + TS 5.x 的时候,复杂递归类型会让语言服务直接吃满 CPU。 -
object类型包含函数和数组。条件类型里写T extends object的时候,记得排除Function,很多时候还要单独处理Array。 -
分布式条件类型的坑。
T extends xxx ? A : B当 T 是联合类型时,会自动分布到每个成员上。如果你不想要这个行为,用[T] extends [xxx]包一层元组。这个坑不踩一次真的记不住。 -
as const别忘了。想从运行时的数组推导出字面量联合类型,一定要加as const,不然 TS 只会推导出string[]。 -
善用
infer。很多复杂类型的解法,核心都是在条件类型里用infer把某部分「抓」出来。比如Promise<string>里把string取出来:T extends Promise<infer U> ? U : T。
小结
类型体操这东西,看起来很唬人,但核心套路就那么几个:
- 条件类型
T extends U ? A : B—— 分支判断 - 映射类型
{ [K in keyof T]: ... }—— 遍历 + 变换 - 递归 —— 处理嵌套结构
- 模板字面量类型 —— 字符串拼接
- infer —— 模式匹配提取
这 5 个学会了,再加上多写多练,日常业务中的类型问题基本都能搞定。不需要去刷 type-challenges 里那些 extreme 难度的变态题,那些真的只是面试炫技用的。
我的建议是:先把这 5 道题自己手敲一遍,然后回去看看自己项目里有没有 as any 可以消灭的地方。每消灭一个 any,你的类型功力就长一分。
如果你也有什么好用的类型工具或者踩过的坑,评论区聊聊。