映射类型 remove 修饰符 -readonly 与 -? 的组合效果

写表单状态、配置对象、API 返回数据这些类型时,几乎每个人都遇到过同一类麻烦:上游类型把字段标成了 readonly,下游需要可写副本;上游把字段标成可选 ?,下游需要一份字段齐全的完整对象。手写一个新 interface 能解决,但字段一多就会和上游脱节——上游加字段,下游忘了同步,编译不报错,运行时才炸。TypeScript 的映射类型(mapped

写表单状态、配置对象、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 的变化。