资讯详情

TypeScript映射类型深度对比:Readonly<Record>与手写修饰符的差异

📅 2026/10/12 1:54:13 | 华诺云谱 👁 阅读
TypeScript映射类型深度对比:Readonly<Record>与手写修饰符的差异
刚接触 TypeScript 的时候我一度把工具类型当成黑盒需要只读就用ReadonlyT需要字典就用RecordK, V。直到有次在同事的代码里翻到这么一行——type X { readonly [K in Key]-?: V }——我整个人愣住了。这个写法到底是什么意思它和标题里那个ReadonlyRecordK, V原文写成Readonly《Record《K, V》》是不是一回事后来我花了一整个下午把Readonly、Record、映射类型、修饰符加减号全部摊开对比了一遍踩了几个坑也终于搞明白了这两类写法在输出、语义和适用场景上的真实差异。这篇文章就把整个拆解过程完整记录下来。1. 一看到这种写法就发懵先弄清楚它俩各自是什么先别管标题里的中文书名号换成 TypeScript 标准写法就是两个类型表达式ReadonlyRecordK, V{ readonly [K in Key]-?: V }注意第二个写法原文少了值类型V的位置实际使用中你几乎不会看到没有值类型的映射类型所以我下面都按{ readonly [K in Key]-?: V }来讨论。如果你手边代码里真的只有{ readonly [K in Key]-? }没有值类型那多半是笔误或省略了泛型参数。1.1 第一个表达式ReadonlyRecordK, V 其实是一层异态映射加一层同态映射RecordK, V是 TypeScript 官方工具类型最常用的解释是构造一个键为 K、值为 V 的字典类型。网上有大量文章会把Recordstring, number当索引签名来用但严格看源码它是这样的type RecordK extends keyof any, T { [P in K]: T; };这里keyof any的实际结果是string | number | symbol也就是属性键的合法范围。[P in K]是一个典型的映射类型它把K这个联合类型里的每个成员遍历一遍变成对象的属性名。比如type Keys save | cancel; // RecordKeys, string // 等价于 { save: string; cancel: string } type Dict RecordKeys, string;接下来外面再套一层ReadonlyT。官方源码是type ReadonlyT { readonly [P in keyof T]: T[P]; };它遍历被传入类型T的所有键给每个属性加上readonly修饰符。所以type ReadonlyDict ReadonlyRecordKeys, string; // 展开过程 // 1. RecordKeys, string { save: string; cancel: string } // 2. Readonly{ save: string; cancel: string } { readonly save: string; readonly cancel: string }这个最终类型和{ readonly save: string; readonly cancel: string }完全等价你可以放心互相赋值。1.2 第二个表达式{ readonly [K in Key]-?: V } 是一个复合修饰符映射再看{ readonly [K in Key]-?: V }。拆开看[K in Key]同样是映射类型遍历Key这个联合类型readonly是给每个属性加上只读修饰符-?表示移除可选修饰符也就是把可能不存在的属性变成必须存在的属性V是值类型。当Key是字面量联合类型时比如type Keys save | cancel; type A { readonly [K in Keys]-?: string }; type B { readonly [K in Keys]: string };A和B的输出完全一样都是{ readonly save: string; readonly cancel: string }。为什么-?在这里没有产生影响因为Keys这个联合类型本身不携带任何属性是否可选的信息映射类型在创建save和cancel属性时默认它们就是必选的。你再加-?等于给一个本来就是0的数字再减一次零。只有当Key来自某个对象的keyof时-?才会暴露真实作用interface Draft { id?: number; title: string; } // 结果{ readonly id: number; readonly title: string }注意 id 的可选没了 type A { readonly [K in keyof Draft]-?: Draft[K] }; // 结果{ readonly id?: number; readonly title: string }readonly 保留可选 type B ReadonlyDraft;对比非常明显同样是想做只读对象Readonly会保留原来的可选性而-?会让所有属性变成必选。这就是标题里这两个表达式最核心的区别也是很多人绕晕的根源。2. 展开后对比四种不同写法的实际输出光看定义还不够我习惯用几个可以直接跑的类型脚本做一次类型等价性验证。这里定义两组场景一组是纯字典一组是从实体接口派生的对象。2.1 纯字典场景四种写法输出几乎没差别先定义一个键联合类型type Keys name | age;然后分别写四种映射方式type T1 ReadonlyRecordKeys, string; type T2 { readonly [K in Keys]: string }; type T3 { readonly [K in Keys]-?: string }; type T4 { [K in Keys]-readonly [K in Keys]?: ... }; // 这行不要写纯属混淆T4 我建议直接忽略因为写成[K in Keys]-readonly又会引入移除只读的修饰符不是这里讨论的重点。前三种type T1 ReadonlyRecordKeys, string; type T2 { readonly [K in Keys]: string }; type T3 { readonly [K in Keys]-?: string }; // 验证互相赋值不报错 declare const v1: T1; const v2: T2 v1; const v3: T3 v2;这种代码放到 TypeScript 编译器里不会产生任何赋值错误。也就是说在纯粹的字典场景下ReadonlyRecord...和手写映射类型是同一件事的两种表达。区别只在可读性和项目风格不在最终类型。2.2 实体对象场景Readonly 和 -? 的差异立刻显现把Key换成真实接口的keyof结果就完全不同了。我用一个表单草稿类型来演示interface FormModel { id?: number; title: string; tags: string[]; }写法输出可选性变化只读变化ReadonlyFormModel{ readonly id?: number; readonly title: string; readonly tags: string[] }保留id可选全部只读{ readonly [K in keyof FormModel]: FormModel[K] }{ readonly id?: number; readonly title: string; readonly tags: string[] }保留id可选全部只读{ readonly [K in keyof FormModel]-?: FormModel[K] }{ readonly id: number; readonly title: string; readonly tags: string[] }id变成必选全部只读ReadonlyRequiredFormModel{ readonly id: number; readonly title: string; readonly tags: string[] }id变成必选全部只读看到倒数第二行和最后一行了吗{ readonly [K in keyof FormModel]-?: FormModel[K] }等价于ReadonlyRequiredFormModel而不是ReadonlyFormModel。很多人以为它等价于后者实际上它在只读之上又叠加了必选。这个差异在业务里非常致命。如果你只是想给一个已有的对象加只读保护同时保留每个字段原有的可选语义那应该用ReadonlyT或者不带-?的映射。如果你还想把可选字段清掉让对象的所有属性都变成必须传入的稳定结构那才需要-?或Required。2.3 修饰符加减号TypeScript 映射类型的隐藏规则TypeScript 3.0 之后映射类型支持在一对方括号内部应用和-前缀来调整修饰符readonly默认是readonly所以readonly和readonly效果一样?默认是?所以?和?效果一样-?表示移除可选修饰符-readonly表示移除只读修饰符可以组合比如-readonly [K in keyof T]-?: T[K]会得到全部必选、全部可变的类型。这个规则解释了为什么官方工具包里会有Partial、Required、Readonly这些长相相似但效果不同的工具。PartialT的源码是type PartialT { [P in keyof T]?: T[P]; };RequiredT的源码是type RequiredT { [P in keyof T]-?: T[P]; };ReadonlyT的源码是type ReadonlyT { readonly [P in keyof T]: T[P]; };三个工具都基于同态映射类型只是修饰符的组合不同。如果你理解了-?的本质再回头看{ readonly [K in Key]-?: V }会清晰很多它就是在映射过程中同时声明每个属性只读并且每个属性必选。3. 业务里到底用哪个字典场景与实体场景两套答案理论对比再多落到实际项目里依然需要做判断。我的个人经验是先分清楚你是在做字典类型还是在做实体类型。这两类场景的选择逻辑不一样。3.1 字典对象场景优先 ReadonlyRecord...所谓字典类型就是键集合和值类型相对独立属性彼此没有强关联。典型例子是按钮文案映射type ActionKey save | cancel | delete; type ActionText ReadonlyRecordActionKey, string; const text: ActionText { save: 保存, cancel: 取消, delete: 删除, }; // 编译报错text.delete 是只读属性 text.delete 移除;这个写法非常直观先声明有哪些键再声明值是什么类型最后用Readonly包一层表示这份配置在运行时不该被修改。如果你手写type ActionText2 { readonly [K in ActionKey]: string };作用虽然一样但阅读代码的人必须先理解[K in ActionKey]是映射类型才能反应过来这其实是一个字典。对于只想表达业务含义的人来说ReadonlyRecordActionKey, string显然是更自文档化的选择。3.2 实体对象场景需要留意是否要连可选性一起改变实体对象往往来自接口、数据库模型或前端状态结构字段天然带有可选项interface UserEntity { id: number; nickname?: string; email?: string; }如果只是想给外部调用方一个不可篡改的只读视图那么type UserSnapshot ReadonlyUserEntity;它会保留nickname和email的可选性。这样你依然不能确定某个字段是否存在必须在访问时做判空。这是符合业务实际的因为后端返回的数据里这两个字段确实可能缺失。如果某个模块要求所有字段必须存在且不可修改比如做版本快照比对那就用type UserSnapshot ReadonlyRequiredUserEntity;等价的手写写法是type UserSnapshot { readonly [K in keyof UserEntity]-?: UserEntity[K]; };到这一步你可以看到手写映射和组合工具是同一层能力只是工具类型让意图更明显。我个人倾向使用组合工具因为Required这个单词直接表达了去掉可选代码评审时一眼就看懂。3.3 一个很容易被忽略的边界嵌套结构和数组无论选哪种这些只读都是浅层只读。比如type Conf ReadonlyRecordstring, string[]; declare const conf: Conf; // 可以给数组 push因为 readonly 只作用在属性层级 conf.list?.push(new);等等这个例子里conf.list是string[]不是readonly string[]所以push是合法的。很多人以为字段只读后字段里的数组也自动只读其实完全没有。如果你确实需要数组内容不可变得自己处理type ReadonlyDict ReadonlyRecordstring, readonly string[];这样conf.list的类型就是readonly string[]push才会报错。标题里提到的两个表达式在这一点上完全一样都没有深度只读的能力。这也引出了下一个问题业务里什么时候需要自己实现 DeepReadonly。4. 我实际踩过的坑修饰符被“打平”和语义偏移理论讲再多都不如真实报错来得深刻。下面三个坑都是我在项目里碰到过的每一个都跟Readonly、Record、-?的组合有关。4.1 坑一在通用工具里用 -? 把可选字段强行变成必选有一段时间我在做表单回填组件希望拿到表单模型后生成一个与之对应的只读视图。一开始我图省事写了一个通用工具type ReadonlyViewT { readonly [K in keyof T]-?: T[K]; };然后接进组件interface CustomerForm { id?: number; name: string; phone?: string; } function renderView(data: ReadonlyViewCustomerForm) { // ... } // 调用侧只传 name renderView({ name: 张三 });编译直接报错缺少id和phone。我第一反应是我只想加只读为什么多了一堆必填校验。后来才意识到-?把两个可选字段全部变成了必选字段而调用方确实没有传这两个值。解决办法很简单去掉-?type ReadonlyViewT { readonly [K in keyof T]: T[K]; };或者直接用ReadonlyT。这个坑教会我一个规律在写通用工具类型时不要顺手加修饰符。每加一个-?或?都必须想清楚它对应哪个业务语义。如果没有明确要求移除可选就不要加。4.2 坑二把整个接口返回体包成 ReadonlyRecordstring, unknown 后数组方法全没法用了有一次我在做接口层封装希望给外部返回一个不可变更的通用结构。我写的是interface ApiResponse { code: number; message: string; data: unknown; } type SafeResponse ReadonlyRecordkeyof ApiResponse, unknown;这个类型一出来所有使用方的代码都开始报错。因为data在SafeResponse中的类型是unknown而原来接口里data是有具体业务类型的。我原本想用Record简化字段定义结果把data的类型也统一成了unknown这个类型信息丢失比只读限制本身更糟糕。后来我把写法改为type SafeResponseTData Readonly{ code: number; message: string; data: TData; };这样既能保留data的业务类型又让整个响应对象只读。这里的关键教训是RecordK, V适合所有值类型一致的字典如果对象的每个字段值类型不同它并不合适。套上Readonly也不能弥补这个语义错位。4.3 坑三把联合类型当 Key 时-? 可读性陷阱最后一个坑算不上报错更多是项目维护方面的。团队里有个模块维护者为了实现只读字典写了这么一段type PermissionMap { readonly [K in read | write | execute]-?: boolean; };功能没问题但每次别人看到-?都会疑惑这些键明明是字面量联合类型又没有可选属性写-?是想表达什么有人以为它把 boolean 变成了可选布尔的某种变形也有人以为-?会影响值类型但这些理解都是错的。它只是没有任何副作用地占着位置。最后我们把它统一改成了type PermissionMap ReadonlyRecordread | write | execute, boolean;代码评审速度一下快了很多。手写映射类型并不错但它更适合在确实需要通过keyof T保留修饰符或同时组合多个修饰符的场景出现。对于普通字典Record是更清晰的命名工具。5. 这些对比背后类型建模的三个原则如果你耐心看到了这里应该能感觉到ReadonlyRecordK, V和{ readonly [K in Key]-?: V }的对比并不是一个简单的等价或不等价问题。它背后牵涉到三个更底层的类型建模原则理解了它们以后遇到类似表达式就不会再发懵。5.1 先表达意图再追求更小的等价写法写类型本质上是在跟未来的读者沟通。ReadonlyRecordK, V的意图是一个键集合为 K、值类型为 V 的只读字典哪怕不认识Record的人也能通过单词猜个大概。{ readonly [K in Key]-?: V }的意图则被隐藏在语法细节里读者必须先解析映射类型再理解修饰符的含义最后还要确认-?到底有没有起作用。这不是说手写映射不好而是说在项目代码里工具类型能表达意图时优先使用工具类型。手写映射类型保留给真正需要精确控制修饰符的场景比如你要同时锁死可选性和只读性。5.2 同态映射与异态映射的分界线这是整篇文章最值得记的一句话[K in keyof T]是同态映射因为它基于T自身的键来生成新类型可以继承并调整T属性的可选性、只读性[K in SomeUnion]是异态映射它只根据某个独立的键联合类型生成新类型不继承任何原有属性修饰符。RecordK, V是异态映射ReadonlyT是同态映射。ReadonlyRecordK, V组合起来就是先做异态映射创建字典再做同态映射加只读修饰符所以最终输出中没有可选性可言。而{ readonly [K in Key]-?: V }当Key是普通联合类型时本质也是一次异态映射当Key是keyof T时就变成同态映射-?才会真正影响结果。很多回答喜欢说这两种写法一样其实只对了一半。只有在Key是普通联合类型时它们才一样一旦Key来自keyof T差异立刻显现。这也是为什么网上经常有争议——大家手里的场景根本不同。5.3 工具组合优先不要推翻标准工具如果你确实需要每个属性都只读且必选我更推荐这样组合type ReadonlyRequiredT ReadonlyRequiredT;如果类型是字典还可以再包一层Recordtype ReadonlyDictK extends PropertyKey, V ReadonlyRecordK, V;其中PropertyKey是内置类型等于string | number | symbol。这个泛型工具绝对值Record...手写映射来得清晰也避免把-?写出歧义。最后再分享一个我在实际项目里常用的判断流程看到一个新的类型表达式先翻译成基础操作 修饰符操作再看Key是不是来自某个对象的keyof。如果是字典联合键-?基本可以忽略如果是keyof T-?就是一次隐式Required。想清楚这一点再决定保留哪一种写法就不会被这两行相似的类型定义反复折磨了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑