- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
本篇指南以 questions/00009-medium-deep-readonly/README.pt-BR.md(英文版见 README.md)为骨架展开:它要求实现一个泛型
DeepReadonly<T>,把对象的所有属性及其子对象递归地标记为只读。在 type-challenges 仓库中,本题是中等难度(medium),标签为readonly、object-keys、deep,与第 7 题 Readonly、第 8 题 Readonly 2 构成由浅入深的递进序列。读完本文,你将掌握递归映射类型、函数/数组/元组的特判处理,以及联合类型分发在条件类型中的行为,并能通过仓库内的测试用例在本地验证自己的实现。
挑战背景:从单层只读到深层只读
本题由 Anthony Fu 创作,仓库通过info.yml记录了题目的元数据:难度为medium,标签为readonly、object-keys、deep,并声明关联题目为第 7、8 题(见 questions/00009-medium-deep-readonly/info.yml)。要理解 Deep Readonly 的价值,先看它所在的递进序列:
- 第 7 题 Readonly(简单):不用内置
Readonly<T>,自行实现"将所有属性置为只读"的单层版本; - 第 8 题 Readonly 2(中等):引入第二个类型参数
K,只把K指定的属性设为只读,未提供K时退化为Readonly<T>; - 第 9 题 Deep Readonly(中等):不再停留在顶层,而是要求递归处理所有子对象,这是前两题的自然延伸。
内置的Readonly<T>只处理一层:Readonly<{ x: { a: 1 } }>得到{ readonly x: { a: 1 } },内层的a依然可写。Deep Readonly 的价值就在于把只读约束传播到对象图的每一个节点,这在配置对象、嵌套状态等场景中非常实用——一旦编译通过,运行时就不再可能出现深层属性被误改。
题目目标:DeepReadonly 的定义与假设
原文档(README.pt-BR.md)给出了清晰的目标描述:实现泛型DeepReadonly<T>,将对象的每一个参数及其子对象递归地设为只读(torne todos os parâmetros de um objeto - e seus subobjetos recursivamente - somente leitura)。
同时文档明确给出两条边界假设:
- 本题只处理对象,数组、函数、类等不需要考虑;
- 但鼓励挑战者自行覆盖尽可能多的不同类型案例("you can still challenge yourself by covering as many different cases as possible")。
这两条假设非常重要:它们决定了"最小可通关实现"与"完整健壮实现"之间的差距——前者只需递归映射对象,而仓库的测试用例(见下文)实际上还覆盖了函数、数组、元组与联合类型,因此想真正通过判题,就必须处理这些"超出最小假设"的情况。
原文档给出的核心示例(完整保留如下):
type X = { x: { a: 1 b: 'hi' } y: 'hey' } type Expected = { readonly x: { readonly a: 1 readonly b: 'hi' } readonly y: 'hey' } type Todo = DeepReadonly<X> // should be same as `Expected`可见:x本身变为只读,同时x内部的a、b也变为只读;y作为叶子属性同样只读。这就是"递归"二字的全部含义。
测试用例揭示的真实判题约束
题目的起点模板极其简单——template.ts 中只有一行占位:
type DeepReadonly<T> = any真正决定实现正确性的,是 test-cases.ts 中的两组用例。它们使用@type-challenges/utils提供的Equal与Expect做严格的结构相等校验:
type cases = [ Expect<Equal<DeepReadonly<X1>, Expected1>>, Expect<Equal<DeepReadonly<X2>, Expected2>>, ]其中Equal的实现位于 utils/index.d.ts,利用函数参数位置的逆变性精确判断两个类型是否完全一致(含 readonly 修饰符差异),因此"差不多对"是过不了判题的。
用例一:函数、深层嵌套对象与数组元组
X1是一个高度嵌套的复合结构(test-cases.ts):
type X1 = { a: () => 22 b: string c: { d: boolean e: { g: { h: { i: true j: 'string' } k: 'hello' } l: [ 'hi', { m: ['hey'] }, ] } } }对应的期望结果Expected1(test-cases.ts)暴露了三个关键事实:
- 函数属性原样保留:
a: () => 22变成readonly a: () => 22,函数本身不被"拆解"。从源码结构可以推断,实现必须先用条件类型把函数类型原样返回,否则keyof (() => 22)会把函数当作普通对象去映射,产生length、name之类的伪属性; - 数组与元组也要递归只读:
l: [ 'hi', { m: ['hey'] } ]变成readonly l: readonly [ 'hi', { readonly m: readonly ['hey'] } ]——元组本身加了readonly,元组里的对象成员也递归只读,数组['hey']同样变为readonly ['hey']; - 深度无上限:
c -> e -> g -> h -> i五层嵌套全部只读,证明实现必须是真正的递归而非有限深度展开。
用例二:联合类型的分发
X2是联合类型{ a: string } | { b: number }(test-cases.ts),期望结果是{ readonly a: string } | { readonly b: number }。这里利用了 TypeScript 条件类型的一个重要机制:当裸类型参数(未被任何结构包裹)遇到条件类型时,联合类型会被分发到每个成员上分别求值。因此DeepReadonly<{ a: string } | { b: number }>等价于分别对{ a: string }和{ b: number }应用DeepReadonly,再把结果重新组成联合——这要求实现中T extends ...的判断必须作用于裸的T,而不能包裹在T[]、keyof T之类结构中,否则会破坏分发。
实现思路:从最小解法到健壮解法
第一步:递归映射对象
最朴素的想法是利用映射类型(mapped type)加递归:
type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> }对纯对象而言它已经满足要求:每一层都递归调用自身,且每一层的键都加上readonly。但它有两个致命缺陷:函数会被错误展开;原始类型(如string)的keyof为空,映射后虽仍等价但语义可疑。测试用例直接否决了这种写法。
第二步:排除函数
对照Expected1中readonly a: () => 22,给递归分支加一道"闸门":
type DeepReadonly<T> = T extends (...args: any[]) => any ? T : { readonly [K in keyof T]: DeepReadonly<T[K]> }T extends (...args: any[]) => any能匹配一切可调用类型(普通函数、箭头函数、方法),命中后原样返回。此时T是裸类型参数,联合类型X2会在两个分支间正确分发,天然满足Expected2。
第三步:让数组与元组同样只读
关键在于映射类型对数组/元组的处理:同态映射(homomorphic mapped type)会保留数组与元组的结构形态。对元组T执行{ readonly [K in keyof T]: ... },K遍历的是数字索引0 | 1 | ...,结果仍是元组,只是每个元素被递归处理并整体加上readonly;对数组同理得到readonly数组。这正是Expected1中readonly l: readonly ['hi', {...}]与readonly m: readonly ['hey']的来源——不需要显式特判数组,第二步的映射分支已经覆盖。
最终的最小可通关实现只需这两行核心逻辑:
type DeepReadonly<T> = T extends (...args: any[]) => any ? T : { readonly [K in keyof T]: DeepReadonly<T[K]> }第四步(自选挑战):覆盖更多类型
原文档鼓励"覆盖尽可能多的不同案例"。例如要保护Date、RegExp这类内部状态不应被展开的对象,或Map、Set等集合容器,可以在闸门处扩展排除列表:
type Primitive = Date | RegExp | Map<any, any> | Set<any> type DeepReadonly<T> = T extends (...args: any[]) => any | Primitive ? T : { readonly [K in keyof T]: DeepReadonly<T[K]> }注意:若对
Map、Set想做到"键/值也只读"的极致深度,可以进一步用ReadonlyMap、ReadonlySet包裹其内部类型,这一步已超出原题要求,属于可选的自我挑战范畴。
边界情况速查
| 输入类型 | 行为 | 说明 |
|---|---|---|
字面量/原始类型(1、'hi'、true) | 原样返回 | keyof为空,映射不产生任何属性 |
函数(() => 22) | 原样返回 | 由T extends (...args: any[]) => any闸门拦截 |
数组(['hey']) | readonly ['hey'] | 同态映射保留数组形态并整体只读 |
元组(['hi', { m: [...] }]) | readonly ['hi', { readonly m: readonly [...] }] | 逐元素递归,索引键保留 |
联合类型({ a: string } \| { b: number }) | 每个成员独立只读后重组联合 | 裸类型参数触发条件类型分发 |
| 深层嵌套对象 | 全层级只读 | 递归无上限 |
本地运行与验证
在仓库中验证实现的方式很直接:每个题目目录都包含一个template.ts(起始为type DeepReadonly<T> = any)和对应的test-cases.ts。把上面第二步的实现填入template.ts后,用 TypeScript 编译器对测试文件做类型检查即可。
仓库根目录使用 pnpm workspace(见 package.json),@type-challenges/utils是 workspace 内部包,提供Equal、Expect等断言工具。典型验证流程:
# 在仓库根目录安装依赖(使 @type-challenges/utils 可用) pnpm install # 对第 9 题的测试用例做类型检查(不产出 JS) npx tsc --noEmit questions/00009-medium-deep-readonly/test-cases.ts若DeepReadonly实现正确,以上命令应零报错;若实现有误(例如函数被拆解、元组未只读、联合未分发),Expect<Equal<...>>会在对应行报出类型不匹配错误。你也可以借助编辑器的 TypeScript 语言服务,将type Todo = DeepReadonly<X>悬停展开,直接观察每一层的readonly是否如Expected所示。
仓库还在 guides/recursive.md 预留了递归类型的专题指南目录(当前为占位内容),配合本类题目学习递归类型是官方推荐的路径。
总结
Deep Readonly 是 TypeScript 类型编程中"递归 + 映射 + 分发"三者交汇的经典题目。从原文档看,它的核心诉求只是"对象及其子对象递归只读";但从仓库的 test-cases.ts 看,一个能通关的实现还必须回答三个额外问题:函数怎么办(原样返回)、数组/元组怎么办(同态映射天然只读)、联合类型怎么办(裸类型参数自动分发)。最终解法浓缩为两行:
type DeepReadonly<T> = T extends (...args: any[]) => any ? T : { readonly [K in keyof T]: DeepReadonly<T[K]> }掌握它,你就同时掌握了递归条件类型、映射类型的同态特性与条件类型分发三大基本功——它们会反复出现在 type-challenges 后续的 harder 题目中。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
Electric 1.0 GA 解读:基于 Postgres 的同步引擎如何做到稳定 API、生产就绪与百万级并发
Electric 1.0 GA 解读:基于 Postgres 的同步引擎如何做到稳定 API、生产就绪与百万级并发 Electric(当前仓库 GitHub_T
示例工程Kubernetes Goat 场景一实战:从 .git 暴露到 TruffleHog 检测代码库中的敏感密钥
Kubernetes Goat 场景一实战:从 .git 暴露到 TruffleHog 检测代码库中的敏感密钥 本文围绕 Kubernetes Goat 的第一
示例工程SANA 安装指南:从零搭建环境到 Diffusers 快速推理实战
SANA 安装指南:从零搭建环境到 Diffusers 快速推理实战 本指南是 SANA(Efficient High Resolution Image Syn
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考