- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
本篇文章聚焦于 type-challenges 仓库中的第 8 号中等难度挑战Readonly 2(编号00008-medium-readonly-2),完整还原题目要求、测试用例与推导过程,并深入到该仓库的模板文件、测试断言与工具类型实现,讲透「部分属性只读」这一泛型映射类型的标准解法。读完本文,你将掌握如何为一个对象类型指定任意属性子集并使其只读、如何利用K extends keyof T做键约束与默认值回退,以及Alike/Expect等测试辅助类型背后的相等性判定原理。
一、题目要求:两个类型参数的MyReadonly2<T, K>
在 type-challenges 中,每道题都存放在questions/目录下独立的编号文件夹中,本题位于 questions/00008-medium-readonly-2,其元数据 info.yml 记录:标题为 Readonly 2,作者 Anthony Fu,难度 medium,标签为readonly、object-keys。
题目要求实现一个泛型MyReadonly2<T, K>,它接收两个类型参数T和K:
T是目标对象类型;K指定T中应被设置为只读(readonly)的属性集合;- 当
K未提供时,行为退化为将T的所有属性变为只读,等价于内置工具类型Readonly<T>。
题目给出的原版示例(README.md)如下:
interface Todo { title: string description: string completed: boolean } const todo: MyReadonly2<Todo, 'title' | 'description'> = { title: "Hey", description: "foobar", completed: false, } todo.title = "Hello" // Error: cannot reassign a readonly property todo.description = "barFoo" // Error: cannot reassign a readonly property todo.completed = true // OK可以看到,'title' | 'description'被标记为只读后,对这两个属性的重新赋值在编译期就会报错;而未被K覆盖的completed仍然可以正常修改。这正是「对象部分属性只读」这一核心语义(中文版标题见 README.zh-CN.md)。
二、起点模板:从any到真正的映射类型
你需要在 template.ts 中完成实现,该文件目前只有一行占位:
type MyReadonly2<T, K> = any这行any意味着无论传入什么类型,结果都会退化为any,显然无法满足只读约束,需要通过类型运算重写。
第一步:理解keyof与K extends keyof T
K的本质是T的键的子集,因此正确写法必须加上约束:
type MyReadonly2<T, K extends keyof T> = ...加上该约束后,一旦调用方传入T中不存在的键,例如MyReadonly2<Todo1, 'title' | 'invalid'>,编译器会直接报错。这一点在测试文件 test-cases.ts 中通过@ts-expect-error明确验证:
// @ts-expect-error type error = MyReadonly2<Todo1, 'title' | 'invalid'>@ts-expect-error断言下一行必须产生类型错误,否则编译失败。换言之,K必须严格属于keyof T是该题目隐含的硬性要求。
第二步:处理「未提供 K」的默认值
题目要求K缺省时等价于Readonly<T>。在 TypeScript 中,类型参数可以指定默认值,因此需要同时满足「约束」与「默认值」两个条件,写法是:
type MyReadonly2<T, K extends keyof T = keyof T> = ...K extends keyof T = keyof T表达了两层含义:
- 约束(extends):调用时传入的
K必须是T的键; - 默认值(=):不传第二个参数时,
K自动取keyof T,即全部键。
这样,「不传 K 时全部只读」的要求就与内置Readonly<T>对齐了。
第三步:用映射类型 + 交集组装结果
方案一:先对K命中的键做只读映射,再取交集合并剩余键:
type MyReadonly2<T, K extends keyof T = keyof T> = { readonly [P in K]: T[P] } & Omit<T, K>拆解如下:
{ readonly [P in K]: T[P] }:遍历K中的每个键,产出带readonly修饰符、取值类型为T[P]的属性;Omit<T, K>:从T中剔除K命中的键,保留其余属性,同时保留这些属性原有的可选(?)与只读修饰符;&:将两部分交叉组合,得到最终类型。
方案二:复用内置工具类型,语义更显式:
type MyReadonly2<T, K extends keyof T = keyof T> = Readonly<Pick<T, K>> & Omit<T, K>Pick<T, K>先取出被选中的键及其原有修饰符,Readonly再统一加上只读,最后与Omit<T, K>交叉。两种写法对本题测试用例均成立。
三、测试用例逐条验证:可选、已只读与键约束
test-cases.ts 中定义了三个接口与一个期望类型:
interface Todo1 { title: string description?: string // 可选属性 completed: boolean } interface Todo2 { readonly title: string // 原本就已只读 description?: string completed: boolean } interface Expected { readonly title: string readonly description?: string // 可选性被保留 completed: boolean }四条断言如下:
type cases = [ Expect<Alike<MyReadonly2<Todo1>, Readonly<Todo1>>>, Expect<Alike<MyReadonly2<Todo1, 'title' | 'description'>, Expected>>, Expect<Alike<MyReadonly2<Todo2, 'title' | 'description'>, Expected>>, Expect<Alike<MyReadonly2<Todo2, 'description'>, Expected>>, ]逐条解读:
MyReadonly2<Todo1>未提供 K:结果必须与内置Readonly<Todo1>完全一致,验证默认参数= keyof T的行为;MyReadonly2<Todo1, 'title' | 'description'>:title与description变为只读,且description的可选修饰符?被保留(Expected中是readonly description?: string),completed保持可变;MyReadonly2<Todo2, 'title' | 'description'>:Todo2.title原本就是readonly,重映射后依然是只读,结果与Expected一致,说明实现不会破坏已有修饰符;MyReadonly2<Todo2, 'description'>:只针对单个键,title维持其原有的只读属性(经由Omit保留),同样匹配Expected。
关于Alike:为什么用&也能通过相等断言
测试中使用的Alike<X, Y>与Expect<T>来自 utils/index.d.ts:
export type Expect<T extends true> = T export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false export type MergeInsertions<T> = T extends object ? { [K in keyof T]: MergeInsertions<T[K]> } : T export type Alike<X, Y> = Equal<MergeInsertions<X>, MergeInsertions<Y>>要点在于:
Equal使用「基于函数签名的可赋值性」这一严格手段判断两个类型是否等价,能区分any、联合类型、只读修饰等细微差异;MergeInsertions会递归地把交叉类型(如{ readonly title: string } & { description?: string; completed: boolean })展开合并成普通对象结构,再交给Equal比较;Alike将两者结合,因此我们解法中用&拼出的交叉类型,可以与被断言为普通对象的Expected通过Alike的严格相等校验。
这也解释了为什么Omit<T, K>必须原样保留非选中键的修饰符:如果description的可选性在中间步骤丢失,MergeInsertions后产物将与Expected不相等,Expect会因extends true失败而报错。
四、易错点与边界情况
- 忘记默认参数:只写
K extends keyof T而不给= keyof T,第一条断言MyReadonly2<Todo1>将因缺少类型实参而报错,无法通过; - 用
Exclude<keyof T, K>代替Omit<T, K>:Omit<T, K>在底层正是由Pick<T, Exclude<keyof T, K>>实现,二者效果一致;但直接写Omit语义更清晰,也避免了嵌套过深; - 对 K 中的键使用
T[P]而非泛化取值:映射类型{ readonly [P in K]: T[P] }中T[P]能精确索引到原属性类型,若写成any或泛化类型会破坏类型精度; @ts-expect-error的存在意义:它强制要求K超出keyof T时必须在编译期报错,这从测试层面锁死了「键约束」这条规则,实现时切勿通过K extends string等宽泛约束绕过。
五、挑战的定位:从 Readonly 到 Deep Readonly
在题目的「相关挑战」导航中(见 info.yml 的related: 7, 9),本题连接着两个相邻挑战,构成完整的只读工具类型学习路线:
| 挑战编号 | 名称 | 难度 | 核心差异 |
|---|---|---|---|
| 7 | Readonly | easy | 全部属性只读,Readonly<T>的直接手写 |
| 8(本题) | Readonly 2 | medium | 按属性子集只读,支持 K 缺省回退 |
| 9 | Deep Readonly | medium | 递归处理嵌套子对象,逐层加只读 |
三者一脉相承:第 7 题解决「全量只读」,第 8 题在它的基础上引入「第二类型参数 + 键约束 + 默认值」的组合技巧,第 9 题则进一步要求递归深入对象内部。可以这样认为:第 8 题的{ readonly [P in K]: T[P] } & Omit<T, K>正是第 9 题递归版本中「对每个键施加只读并继续下钻」的基本构件。做完本题后,顺着 README.md 底部的相关挑战链接继续攻克第 9 题,能自然强化映射类型与递归类型的综合运用能力。
六、总结
最终可在 template.ts 中提交的实现为:
type MyReadonly2<T, K extends keyof T = keyof T> = { readonly [P in K]: T[P] } & Omit<T, K>它用四步走完整回答了题目:
K extends keyof T约束键必须是T的合法键;= keyof T提供默认值,缺省时退化为Readonly<T>;{ readonly [P in K]: T[P] }只对选中的键施加只读;& Omit<T, K>合并剩余键并保留其原有可选/只读修饰符。
对照 test-cases.ts 的四条断言与一条@ts-expect-error,该实现覆盖了「全量只读、部分只读、保留可选性、兼容既有 readonly、非法键报错」全部场景。理解这一题的推导链,你就掌握了 TypeScript 泛型工具类型中最常用的一组核心技巧:映射类型(mapped type)、键约束(extends keyof)、默认类型参数(= default)与类型交叉(&)的组合。
- 示例工程
【免费下载链接】type-challenges
Collection of TypeScript type challenges with online judge
相关推荐
es-toolkit/fp 数据末位 without:在 pipe 管线中原生剔除指定值
es toolkit/fp 数据末位 without:在 pipe 管线中原生剔除指定值 without (FP 变体)是 es toolkit 函数式编程模块
示例工程type-challenges 第 8 题 MyReadonly2:实现「对象部分属性只读」的进阶泛型
type challenges 第 8 题 MyReadonly2:实现「对象部分属性只读」的进阶泛型 本篇文章以 questions/00008 medium
示例工程SANA 安装指南:从零搭建环境到 Diffusers 快速推理实战
SANA 安装指南:从零搭建环境到 Diffusers 快速推理实战 本指南是 SANA(Efficient High Resolution Image Syn
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考