TypeScript 如何做深度 Readonly
你好呀!今天咱们聊一个前端开发里常用的小知识点——怎么用TypeScript实现“深度只读”。先从一个日常场景说起吧:你有没有写过这样的代码?定义了一个用户信息对象,想保证它的属性不会被意外修改,于是给它套了个Readonly,结果转头就发现,对象里藏着的另一个对象的内容,居然被随便改了?这就是浅只读的坑,咱们得解决这个问题。
先搞懂浅Readonly的坑
TypeScript原生的Readonly类型,其实是“浅”的——它只能保证对象最外层的属性不能重新赋值,但内层嵌套的对象是“裸奔”的。举个例子:

`typescript
// 原生Readonly只能锁外层
type User = {
name: string;
address: { city: string }; // 嵌套对象
};
const user: Readonly<User> = {
name: "小明",
address: { city: "北京" }
};
// 居然能改!这就是浅只读的问题
user.address.city = "上海"; // 语法上居然不报错!
`
是不是很意外?这就像你给笔记本套了个带锁的外壳,能保证外壳本身不会被换掉,但笔记本里的笔记本页(内层对象),你还是能随便写画——这就是浅只读的局限,咱们需要的是“连笔记本页都不能改”的深度只读。
怎么实现深度Readonly?
要做深度的,核心思路就是:对每个属性递归判断,如果属性是对象类型,就给它也套上深度Readonly,如果是基础类型(比如字符串、数字),就直接锁死。TypeScript的类型系统刚好支持这种递归的写法,咱们可以自己写一个工具类型:
`typescript
// 自定义深度Readonly类型
type DeepReadonly<T> = {
readonly [K in keyof T]:
// 如果属性是对象且不是函数,就递归用DeepReadonly
T[K] extends object ? DeepReadonly<T[K]> :
// 否则(基础类型)直接加readonly
T[K];
};
`
这个工具类型怎么用?还是刚才的User对象试试:
`typescript
const user: DeepReadonly<User> = {
name: "小明",
address: { city: "北京" }
};
// 现在改内层会报错了!完美锁住
user.address.city = "上海"; // 语法提示:无法分配到"city",因为它是只读属性
`
是不是很管用?这就相当于你把笔记本里的每一页都用订书机钉死了,想改哪页都不行,连换页都做不到。
要注意的小细节
当然,这个实现还有几个容易踩的小坑,得提一下:
- 函数要排除:如果你的对象里有方法(比如user.sayHi = () => {}),那
object类型会把函数也包含进去,递归就会处理函数,而函数加了readonly也没用,甚至可能出问题。所以刚才的判断里,其实可以加个Exclude排除函数,改成T[K] extends object ? Exclude<T[K], Function> extends never ? T[K] : DeepReadonly<T[K]> : ...,这样函数就不会被递归处理了。 - 数组的情况:如果你的嵌套是数组,比如
{ hobbies: string[] },刚才的类型会直接把数组当成对象,递归后变成DeepReadonly<string[]>,但数组的元素本身是readonly吗?其实还可以优化,让数组的每个元素也变成只读,比如加个判断T[K] extends Array<infer U> ? Array<DeepReadonly<U>> : ...,这样数组里的内容也能被锁死。
实际开发里的小用处
为什么要做深度Readonly?日常开发里它能帮你避免很多bug:比如把配置对象设成深度只读,防止其他代码不小心改了全局配置;或者在状态管理(比如Redux)里,保证状态不会被意外修改,让数据更安全。就像你给重要文件套了多层密封袋,哪怕有人不小心碰到,也改不了核心内容。
其实除了自己写,现在很多前端工具库也会提供类似的深度只读工具函数,比如Lodash的cloneDeepWith配合类型处理,或者Immer库的只读状态,本质都是用了递归的思路。核心还是理解“浅”和“深”的区别,别再被原生Readonly的坑骗到啦。