TypeScript 类型体操实战:5 道题从懵逼到开窍,附完整解题思路

0 阅读1分钟

上周组里来了个实习生,Review 他代码的时候发现满屏 as any,我说兄弟你这写的是 AnyScript 啊。他很委屈:「学长,TS 类型太难了,稍微复杂一点就不会写了。」

说实话我特别理解这种感受,因为我自己也是从 any 大法一路走过来的。直到有一天线上出了个 bug,排查了半天发现就是类型不对导致的——一个本该是 string[] 的字段被当成 string 用了,as any 完美绕过了编译检查。那次之后我痛定思痛,花了两周专门练类型体操,现在虽然不敢说多厉害,但至少不用写 any 了。

今天把我觉得最有实战价值的 5 道类型体操题整理出来,不是那种纯炫技的变态题,而是你日常写业务代码真的会用到的模式。

先说结论

题目难度实战价值核心知识点
DeepReadonly⭐⭐递归类型、条件类型
PathKeys⭐⭐⭐模板字面量、递归
PickByType⭐⭐映射类型、条件过滤
TupleToUnionindexed 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

拆解一下:

  1. T extends object —— 只处理对象类型
  2. keyof T & string —— 确保 key 是 string(排除 symbol 和 number)
  3. `${Prefix}${K}` —— 当前层级的路径(比如 'a''a.b'
  4. PathKeys<T[K], `${Prefix}${K}.`> —— 递归下一层,把当前路径作为前缀传下去
  5. [keyof T & string] —— 最后用 indexed access 把映射类型展开成联合类型

验证:

type Test = PathKeys<Obj>
// 'a' | 'a.b' | 'a.c' | 'a.c.d' | 'e'  ✅

踩坑

我第一版没加 T extends Function 的判断,结果遇到 Date 类型的字段直接爆炸——Date 也是 object,递归进去就开始展开 getTimetoISOString 这些方法的 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 组件,visibleonClose 是必选的,其他都可选:

type ModalProps = PartialPick<FullModalProps, 'title' | 'width' | 'footer'>

比手动写一堆 ? 优雅多了。

踩坑记录汇总

练类型体操这段时间踩的坑,统一记一下:

  1. 递归类型一定要加终止条件。不然 TS 要么报 excessively deep,要么直接卡死 IDE。特别是用 VSCode + TS 5.x 的时候,复杂递归类型会让语言服务直接吃满 CPU。

  2. object 类型包含函数和数组。条件类型里写 T extends object 的时候,记得排除 Function,很多时候还要单独处理 Array

  3. 分布式条件类型的坑T extends xxx ? A : B 当 T 是联合类型时,会自动分布到每个成员上。如果你不想要这个行为,用 [T] extends [xxx] 包一层元组。这个坑不踩一次真的记不住。

  4. as const 别忘了。想从运行时的数组推导出字面量联合类型,一定要加 as const,不然 TS 只会推导出 string[]

  5. 善用 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,你的类型功力就长一分。

如果你也有什么好用的类型工具或者踩过的坑,评论区聊聊。