类型体操(二):DeepReadonly 与递归映射

本节目标

1. Readonly 只防第一层

TS 内置的 Readonly<T> 长这样:

type Readonly<T> = {
  readonly [K in keyof T]: T[K]
}

它只让第一层字段只读。一旦对象嵌套,内层照样能改:

// 运行环境:Node.js 16+ + typescript;类型检查 npx tsc --noEmit ts-l17.ts

interface Config {
  db: { host: string; port: number }
  debug: boolean
}
type R = Readonly<Config>
const c: R = { db: { host: 'localhost', port: 5432 }, debug: false }
// c.debug = true          // ❌ 第一层只读
// c.db.host = '127.0.0.1' // ✅ 竟然还能改!内层没被保护

要让 db.host 也只读,就得「钻进嵌套对象递归处理」。

2. DeepReadonly:递归映射

// 递归映射:对每个字段,如果是「对象」就继续 DeepReadonly,否则加 readonly
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]            // 函数本身不递归(避免把方法也 readonly 包裹出错)
      : DeepReadonly<T[K]>
    : T[K]
}

手推一遍 DeepReadonly<Config> 的展开(重点看内层怎么被包住):

DeepReadonly<Config>
=> {
     readonly db: DeepReadonly<{ host: string; port: number }>  // db 是对象,递归
     readonly debug: boolean                                    // 原始值,直接 readonly
   }
// 继续展开 db 那一层:
DeepReadonly<{ host: string; port: number }>
=> {
     readonly host: string   // 原始值
     readonly port: number   // 原始值
   }
// 最终:db.host / db.port 全部 readonly

这就完成了「逐层 DFS(深度优先遍历)+ 每层套 readonly」。对象的嵌套层级再深,也会被一层层钻进去锁死。

3. 它的几个兄弟类型

同一种「递归映射」套路,换个动作就是另一个工具类型:

// 深层可选(每个字段及其子字段都变 ?)
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// 深层必填(反向操作)
type DeepRequired<T> = {
  [K in keyof T]-?: T[K] extends object ? DeepRequired<T[K]> : T[K]
}

// 把 readonly 去掉(让不可变数据重新可变,常用来「拷贝后修改」)
type Mutable<T> = {
  -readonly [K in keyof T]: T[K] extends object ? Mutable<T[K]> : T[K]
}

注意 -? 和 -readonly 这两个写法:映射类型里加 - 前缀就能「去掉」可选 / 只读修饰符,是 Required/Mutable 的实现核心。

4. 边界:哪些该递归、哪些该跳过

DeepReadonly 里那句 T[K] extends object ? ... : T[K] 看似简单,其实藏着几个判断:

5. 实战:冻结一份配置

const baseConfig: DeepReadonly<Config> = {
  db: { host: 'localhost', port: 5432 },
  debug: false,
}
// baseConfig.db.host = '1.2.3.4' // ❌ 编译期直接拦下,杜绝运行时误改

前端拿到「全局状态 / 配置」后常希望它不可被随意修改,DeepReadonly 把「不可变」变成硬约束而非口头约定。

名词解释

递归映射类型(Recursive Mapped Type):在 [K in keyof T] 的「字段值」处再次调用自己(DeepReadonly<T[K]>),从而一层层钻进嵌套对象。它等价于对对象做「深度优先遍历」并在每层套上 readonly。是处理「嵌套结构批量改造」的终极手段。

-? 与 -readonly 修饰符:映射类型里,? 表示可选、readonly 表示只读;前缀加 - 即可「移除」该修饰(-? 去可选 = Required 的核心,-readonly 去只读 = Mutable 的核心)。这是 TS 给映射类型的「反向开关」。

DeepReadonly 的应用场景:前端拿到「全局状态 / 配置」后常希望它不可被随意修改,避免意外副作用。DeepReadonly 能在编译期禁止任何层级的赋值,把「不可变」变成硬约束而非口头约定。

课后练习

练习 1:写 DeepPartial<T>:把每一层字段都变成可选。

答案:

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

练习 2:为什么 DeepReadonly 里要单独排除 Function?

答案:函数是「可调用的值」而非「可遍历的属性容器」。若不排除,递归会把函数也当对象处理,导致 () => void 被错误地变成 readonly (() => void) 这类无意义甚至报错的形态。排除函数保证只处理「数据对象」。

练习 3:写 Mutable<T> 把任意深度的 readonly 全部去掉。

答案:

type Mutable<T> = {
  -readonly [K in keyof T]: T[K] extends object ? Mutable<T[K]> : T[K]
}

练习 4:DeepReadonly 会连数组元素一起锁死,这总是你想要的吗?什么场景你可能不希望数组被递归?

答案:通常是想要的(整个结构不可变)。但如果你希望「外层配置对象只读、内层是一个允许被替换 / 追加的数组」,就该在 T[K] extends object 里额外排除 any[](即 T[K] extends any[] ? T[K] : ...),让数组保持原样。

总结

递归映射类型是「映射类型」的完全体。我的观点:它非常适合表达「不可变数据」「深合并配置」这类需求,但写的时候一定要像上面那样处理边界(函数、数组、特殊内建对象),否则递归会咬到自己。新手最容易写出「对 Function 也递归」导致类型爆掉。另一个经验:这类深度类型推导很吃编译性能,对象嵌套超过 5 层就要警惕 tsc 变慢。所以我的建议是——DeepReadonly/DeepPartial 这类工具类型「写好一次、全项目复用」即可,业务里直接 import,别每个文件手搓一遍。