写表单状态、配置对象、API 返回数据这些类型时,几乎每个人都遇到过同一类麻烦:上游类型把字段标成了 readonly,下游需要可写副本;上游把字段标成可选 ?,下游需要一份字段齐全的完整对象。手写一个新 interface 能解决,但字段一多就会和上游脱节——上游加字段,下游忘了同步,编译不报错,运行时才炸。TypeScript 的映射类型(mapped type)提供了两个”减号修饰符”:-readonly 去掉只读,-? 去掉可选。本文把这两个修饰符单独讲透,再讲它们组合使用时的真实行为、边界情况,以及几个容易踩坑的报错信息与修复方式。
先看基线:映射类型里修饰符是怎么作用上去的
映射类型的核心语法是 [K in keyof T],它遍历 T 的所有键,为每个键生成成员。TypeScript 允许在键上加修饰符:+readonly / -readonly 控制只读性,+? / -? 控制可选性。不写时是”保留原样”,写 + 或什么都不写是”加上”,写 - 是”去掉”。
interface User {
readonly id: number;
readonly name: string;
email?: string;
age?: number;
}
// 保留全部修饰符:与 User 完全一致
type Same = { [K in keyof User]: User[K] };
// 去掉只读
type Writable = { -readonly [K in keyof User]: User[K] };
// 去掉可选(字段全部变成必填)
type Required1 = { [K in keyof User]-?: User[K] };
// 两个都去掉
type MutableRequired = { -readonly [K in keyof User]-?: User[K] };
注意修饰符的位置不是随便放的。-readonly 写在方括号内部、键名之前;-? 写在键名之后、索引签名类型之前(即紧跟在 keyof T 之后)。写成 [K in keyof T-?] 会被解析成别的意思并直接报错,这是新手最常见的语法错误之一。
单独使用 -readonly:只动可写性,可选性原封不动
先用编译器验证一个关键事实:-readonly 只剥离只读,不会顺手把可选字段变成必填。
interface Config {
readonly host: string;
readonly port: number;
timeout?: number;
}
type WritableConfig = { -readonly [K in keyof Config]: Config[K] };
// 展开后等价于:
// { host: string; port: number; timeout?: number; }
const c: WritableConfig = { host: '127.0.0.1', port: 8080 };
c.host = 'localhost'; // 合法,只读已被剥离
c.timeout = 3000; // 合法,timeout 本来就是可选
// 但可选性没被动过,下面这行不报错
const c2: WritableConfig = { host: 'a', port: 1 };
很多文章把这一点说错,以为 -readonly 会顺带处理可选。不会。它只改一个维度,另一维度原样继承。这是设计上的正交性,理解它才能正确预测组合后的类型。
一个容易忽略的细节:readonly 只是编译期约束。剥离它不会改变运行时行为,Object.freeze 冻结的对象被赋成 WritableConfig 后赋值照样在严格模式下抛 TypeError。类型系统不负责运行时保护。
单独使用 -?:会连同 undefined 一起剥离
-? 的行为比名字看起来更微妙。它不只是”去掉问号”,还会从字段类型里剔除 undefined。这正是它和内置工具类型 Required<T> 的语义来源。
interface Payload {
id?: number;
tags?: string[] | undefined;
note?: string;
}
type Full = { [K in keyof Payload]-?: Payload[K] };
// 结果:
// { id: number; tags: string[]; note: string; }
declare const p: Full;
const n: number = p.id; // 不再需要非空断言
对比一下:如果只写 [K in keyof Payload]: Payload[K] 得到的是 { id: number | undefined; ... }(在未开启 exactOptionalPropertyTypes 的默认配置下,读取可选属性本来就带 undefined)。而 -? 会把 undefined 从联合类型里摘掉,因此 p.id 的类型是干净的 number。
这个”顺带剥离 undefined”在组合场景里是一把双刃剑:好处是拿到真正完整的对象类型,坏处是当上游字段类型本身就显式包含 undefined(例如 value?: string | undefined)时,你以为保留的类型信息被吃掉了。
组合使用:-readonly 与 -? 的叠加顺序与最终形态
两个修饰符可以写在同一行,TypeScript 对它们的处理没有先后依赖,结果就是把两个维度都归零:所有字段既可写又必填,且类型中不再含 undefined。
interface Article {
readonly id: number;
readonly title: string;
readonly tags?: string[];
readonly views?: number;
}
type ArticleDraft = { -readonly [K in keyof Article]-?: Article[K] };
// 等价于:
// {
// id: number;
// title: string;
// tags: string[];
// views: number;
// }
const draft: ArticleDraft = {
id: 1,
title: 'TypeScript 映射类型',
tags: [],
views: 0,
};
draft.views += 1; // 合法:可写
draft.tags.push('ts'); // 合法:可写
// 少给一个字段就报错
// const bad: ArticleDraft = { id: 1, title: 'x', tags: [] };
// Property 'views' is missing in type '{ id: number; title: string; tags: never[]; }'
// but required in type 'ArticleDraft'.
既然顺序无所谓,写成两行嵌套的映射类型等价吗?等价。下面三种写法产生结构上完全相同的类型(结构等价指编译器视为互相可赋值,字段名、修饰符、类型都一致):
// 写法 A:一行写两个修饰符
type A = { -readonly [K in keyof Article]-?: Article[K] };
// 写法 B:先剥只读,再剥可选
type Step1 = { -readonly [K in keyof Article]: Article[K] };
type B = { [K in keyof Step1]-?: Step1[K] };
// 写法 C:先剥可选,再剥只读
type Step2 = { [K in keyof Article]-?: Article[K] };
type C = { -readonly [K in keyof Step2]: Step2[K] };
// 下面四个断言全部通过,证明三者互相可赋值
declare const a: A;
declare const b: B;
declare const c: C;
const ab: B = a;
const ba: A = b;
const ac: C = a;
const ca: A = c;
不过有一个性能层面的建议:优先用写法 A。写法 B/C 会生成中间类型别名,在字段极多的大型对象(几百个属性的配置树)上,类型检查器需要多展开一层,编辑器补全和 tsc --noEmit 的耗时会有可感知的差异。单个映射类型展开最快。
组合后继承关系的陷阱
去掉 readonly 后的类型不再是原类型的子类型关系保持者,赋值方向要小心:
interface Frozen {
readonly x: number;
}
type Loose = { -readonly [K in keyof Frozen]: Frozen[K] };
declare const f: Frozen;
declare const l: Loose;
const l2: Loose = f; // 合法:只读可赋给可写(宽松方向)
const f2: Frozen = l; // 合法:可写也可赋给只读(TS 不阻止)
// 但反过来在运行时会出问题:
f2.x = 1; // 编译期错误:Cannot assign to 'x' because it is a read-only property.
TypeScript 的可写性检查是”防止通过只读引用写”,而不是”防止写入被冻结对象”。把可写类型赋值给只读类型永远合法,于是你能绕一圈拿到只读引用再往回赋,这属于类型系统刻意不做声明的部分,不要依赖它做安全保证。
真实场景一:把表单初始值转成提交体
后台管理里常见的模式是:一份 readonly 的默认配置,加一份允许用户编辑、提交时必须完整的表单模型。用组合修饰符可以把”默认值类型 → 表单类型”做成纯类型变换,上游加字段自动同步。
interface FormDefaults {
readonly username: string;
readonly email?: string;
readonly age?: number;
readonly agreed?: boolean;
}
type FormModel = { -readonly [K in keyof FormDefaults]-?: FormDefaults[K] };
const defaults: FormDefaults = { username: 'guest' };
function buildForm(src: FormDefaults): FormModel {
return {
username: src.username,
email: src.email ?? '',
age: src.age ?? 18,
agreed: src.agreed ?? false,
};
}
const form = buildForm(defaults);
form.age = 20;
console.log(form); // { username: 'guest', email: '', age: 20, agreed: false }
这里真正的收益不是少写几个字,而是编译期强制补全:FormDefaults 新增一个可选字段后,buildForm 的返回值立刻报 Property 'xxx' is missing,逼迫开发者补默认值,而不是让 undefined 静默流进提交请求。
真实场景二:联合类型的分布行为
映射类型遇到联合类型时是按成员分布的,但这里的”分布”来源于 keyof 对联合类型的交叉语义,容易和条件类型的分布混淆。keyof (A | B) 得到的是 keyof A & keyof B,即只保留公共键。
type A = { readonly a: string; readonly shared?: number };
type B = { readonly b: string; readonly shared?: number };
type Union = A | B;
type MappedUnion = { -readonly [K in keyof Union]-?: Union[K] };
// keyof (A | B) = 'shared'
// MappedUnion = { shared: number }
declare const m: MappedUnion;
const s: number = m.shared; // 合法
// m.a 不存在,编译报错
// Property 'a' does not exist on type 'MappedUnion'.
如果想对联合的每个成员分别变换再合并,需要借助条件类型或 DistributiveOmit 这类写法:
type MapEach = T extends unknown
? { -readonly [K in keyof T]-?: T[K] }
: never;
type Expanded = MapEach;
// { a: string; shared: number } | { b: string; shared: number }
declare const e: Expanded;
if ('a' in e) {
const v: string = e.a; // 收窄后可访问
}
这段 T extends unknown ? ... : never 是显式触发条件类型分布的标准手法。unknown 在这里不是随便挑的——任何类型都 extends unknown,所以分发不会丢成员。
常见报错与修复
1. 修饰符写错位置
type Bad = { [K in keyof User]-readonly?: User[K] };
// error TS1005: ']' expected.
// error TS1128: Declaration or statement expected.
原因:-readonly 必须放在键名前面。-? 才能跟在 keyof X 后面。正确写法是 { -readonly [K in keyof User]-?: User[K] }。如果两个修饰符顺序写反成 [K in keyof T]-? -readonly 同样语法错误,-readonly 永远在方括号内最前面。
2. 在非映射类型上使用 -?
type Bad2 = User-?;
// error TS1005: ';' expected. / '=' expected.
修饰符只能出现在映射类型的 [K in ...] 上。想要”全部必填”就老实写映射类型,或者直接用内置的 Required<User>。
3. 以为组合后能修改嵌套只读属性
interface Deep {
readonly meta: { readonly version: number };
}
type Shallow = { -readonly [K in keyof Deep]-?: Deep[K] };
const s: Shallow = { meta: { version: 1 } };
s.meta = { version: 2 }; // 合法:外层解除了
s.meta.version = 3;
// error TS2540: Cannot assign to 'version' because it is a read-only property.
映射类型只作用在被遍历的这一层。深层只读要用递归映射类型,并且要处理数组、函数、Date 这类不该逐键遍历的特殊类型,否则会把 string[] 的键(length、push 等)也映射进来,得到很难修的类型错误。
4. 用 -? 后字段类型意外变成 never
极少见但确实存在:当你对交叉类型做 keyof 合并,两个成员的同名属性类型互斥(例如一个 string 一个 number),交叉后属性类型变 string & number 即 never,-? 只是剥掉可选,剥不掉 never。
type X = { readonly id?: string };
type Y = { readonly id?: number };
type XY = { -readonly [K in keyof (X & Y)]-?: (X & Y)[K] };
// XY = { id: never }
// const xy: XY = { id: 'a' };
// error TS2322: Type 'string' is not assignable to type 'never'.
修复思路是不要在冲突属性上强行合并,改用 Omit 剔除冲突键后重新声明,或把联合类型改成判别联合(discriminated union)。
5. 开启 exactOptionalPropertyTypes 后的行为变化
tsconfig.json 里设置 "exactOptionalPropertyTypes": true 后,foo?: string 不再自动包含 undefined。此时 -? 剥离 undefined 的效果对普通可选字段就没有可剥的东西了,但如果你显式写成 foo?: string | undefined,-? 仍会把 undefined 摘掉,得到 foo: string。两个开关配合时务必写几个类型断言验证,别靠直觉。
可直接复用的几个类型工具
把上面的认知封装成工具类型,放进项目的 types/utils.ts:
/** 去掉只读与可选,得到可写的完整形态 */
export type MutableRequired<T> = { -readonly [K in keyof T]-?: T[K] };
/** 只去只读,保留可选性 */
export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
/** 只去可选,保留只读性 */
export type Complete<T> = { [K in keyof T]-?: T[K] };
/** 对联合类型的每个成员分别应用 MutableRequired */
export type DeepMutableRequired<T> = T extends readonly unknown[]
? { -readonly [K in keyof T]-?: T[K] }
: T extends object
? { -readonly [K in keyof T]-?: DeepMutableRequired<T[K]> }
: T;
interface Nested {
readonly a?: { readonly b?: string };
readonly list?: readonly number[];
}
type R = DeepMutableRequired<Nested>;
// { a: { b: string }; list: number[] }
这段递归实现刻意没有处理函数和 Date,因为 Date 的方法属性会被全部映射成必填函数而破坏类型。生产项目里通常会在 T extends Date | Function | RegExp 上直接短路返回 T,需要时补上即可。
小结
把两个修饰符的行为压缩成一张表:
| 写法 | 只读性 | 可选性 | 类型中的 undefined |
|---|---|---|---|
默认映射 [K in keyof T] |
保留 | 保留 | 保留 |
-readonly [K in keyof T] |
去除 | 保留 | 保留 |
[K in keyof T]-? |
保留 | 去除 | 剥离 |
-readonly [K in keyof T]-? |
去除 | 去除 | 剥离 |
记住三点:修饰符位置写错是最常见的语法错误;-? 会连带剥掉 undefined,这与 Required<T> 一致;映射类型只作用当前层,深层需要自己写递归并处理特殊类型。
FAQ
Q1:-readonly 和内置的 Readonly<T> 能互相抵消吗?
能。Readonly<Mutable<T>> 与 Readonly<T> 结构等价,断言均通过。但注意 Readonly 只加只读不去可选,Required 只去可选不去只读,两者各自只管一个维度,组合时按需叠加。
Q2:为什么 keyof (A | B) 只剩公共键,但我记得映射类型是分布的?
两个机制不同。映射类型本身不会按联合成员展开,是条件类型的 T extends U ? ... : ... 在裸类型参数上才按联合分布。keyof 对联合取的是交叉,所以先得公共键。要对每个成员分别映射,用 T extends unknown ? Mapped<T> : never。
Q3:剥掉 readonly 后能直接改 Object.freeze 的对象吗?
编译能过,运行时不行。ES 模块和 "use strict" 下给冻结对象赋值抛 TypeError: Cannot assign to read only property 'x' of object,非严格模式静默失败。类型层面的可写不等于运行时可变。
Q4:组合修饰符对索引签名有效吗?
有效,且索引签名的 readonly 也会被 -readonly 去除:{ readonly [k: string]: number } 经 -readonly [K in keyof T] 后可以再次赋值。但索引签名的键是 string | number,映射后仍为索引签名,不会展开成具体属性。
Q5:性能上该选一行写两个修饰符还是嵌套两层?
选一行。嵌套两层会引入中间类型别名,字段规模大时需要额外展开。如果类型检查明显变慢,除了合并修饰符,还可以考虑用 interface 显式声明替代深递归映射类型,并用 tsc --extendedDiagnostics 观察 Check time 与 Instantiation count 的变化。