类型体操(二):DeepReadonly 与递归映射
本节目标
- 写一个「深层只读」类型
DeepReadonly<T> - 理解「递归映射类型」如何像 DFS 一样穿透嵌套对象
- 顺手掌握它的几个兄弟:
DeepPartial/DeepRequired/Mutable - 看清递归映射的边界(函数、数组、原始值、特殊内建对象)
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] 看似简单,其实藏着几个判断:
- 为什么排除
Function? 函数是「可调用的值」,不是「可遍历的属性容器」。若不排除,递归会把函数也当对象处理,导致() => void被错误地变成readonly (() => void)这类无意义甚至报错的形态。排除函数保证只处理「数据对象」。 - 数组也是
object:T[K] extends object对数组同样成立,所以DeepReadonly会连数组元素一起锁死——这通常是你想要的(整个数据结构不可变)。如果你只想锁对象、放行数组,需要额外判断T[K] extends any[]。 Date/Map/Set等特殊对象:它们也是object,递归进去会破坏其内部结构。工业级实现通常会加白名单跳过这些内建类型。
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,别每个文件手搓一遍。