Menu

TypeScript Partial 与 Required:示例与深层 Partial

Partial<T> 把 T 的每个属性都变成可选,这正是更新对象或补丁对象的类型。本文讲解 Partial 在更新函数中的用法、为什么它是浅层的、如何编写 DeepPartial、显式 undefined 的陷阱,以及它的反面 Required<T>。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Partial<T> 是一个内置工具类型,它把 T 的每个属性都变成可选。它天然就是更新或补丁操作的类型:调用者只传入发生变化的字段。

Partial<User> 就是 { id?: number; name?: string; email?: string }。编译器仍然会检查你传入的字段:{ nmae: "x" } 或 { name: 42 } 都会报错,这正是 Partial 比宽松的 object 或 any 参数更好的地方。

Partial 是如何定义的

Partial 是 TypeScript 标准库里一个只有一行的映射类型:

type Partial<T> = {
  [P in keyof T]?: T[P];
};

对于 T 的每个键 P,它声明一个类型相同的可选属性。由于它是对 keyof T 做映射,原本带 readonly 的属性会保留 readonly。它只是一个普通类型,运行时没有任何作用:作为 changes 传入的对象无论如何都是同一个对象。

读取 Partial 的属性得到 T | undefined

Partial<T> 的每个属性都可能不存在,所以读取时得到的是属性类型加上 undefined。编译器会要求你处理缺失的情况:

{ ...defaults, ...opts } 是把 Partial<Options> 还原成完整 Options 的常用写法:先展开默认值,再让传入的值覆盖它们。

显式 undefined 的陷阱

可选属性可以不存在,但也可以存在并且值为 undefined。对象展开会用这个 undefined 覆盖真实的值,而结果的类型看不出来:

当补丁来自表单或查询字符串,空字段变成 undefined 时,就会踩到这个坑。编译选项 exactOptionalPropertyTypes(不包含在 strict 中)会让 { name: undefined } 对于 name?: string 成为编译错误,除非你写成 name?: string | undefined,从源头上避免这个问题。

Partial 是浅层的

Partial 只让顶层属性变成可选。嵌套对象如果传入,就必须是完整的:

index.ts(8,3): error TS2741: Property 'tabSize' is missing in type '{ fontSize: number; }' but required in type '{ fontSize: number; tabSize: number; }'.

editor 属性是可选的,但一旦出现,它的类型仍然是 { fontSize: number; tabSize: number },没有任何变化。对于设置对象、API 补丁和测试数据,你常常希望每一层的属性都是可选的。这就需要递归类型。

DeepPartial:递归的 Partial

TypeScript 没有内置的深层版本,但写起来只需几行。函数和数组保持原样,因为把数组元素变成可选会允许 [undefined]:

类型是递归的,合并却不是。applySettings 手动合并 editor,因为对象展开同样是浅层的。lodash 等库提供了通用的深层合并函数(merge),而它的类型定义比上面的类型难写得多。

Required:Partial 的反面

Required<T> 去掉每个属性的 ?。它是用 -? 修饰符定义的,这个修饰符同时会从每个属性的类型中去掉 undefined:

这个模式和 Partial 一样,只是方向相反:API 的使用者传入宽松的配置,内部代码则使用 Required 版本,每个值都确定存在。这里的展开和上面的更新函数有同样的漏洞:调用者如果显式传入 port: undefined,就会用 undefined 覆盖默认值,而只要没开启 exactOptionalPropertyTypes,编译器就会接受。Required 和 Partial 一样是浅层的。

只让部分属性变成可选或必选

Partial 和 Required 作用于每个属性。如果只想改变其中几个,就用 Pick 和 Omit 把类型拆开再拼回去:

type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;

interface Post {
  id: number;
  title: string;
  body?: string;
}

type NewPost = PartialBy<Post, "id">;      // id optional, title required, body optional
type Published = RequiredBy<Post, "body">; // body now required
类型作用是否深层
Partial<T>每个属性都变为可选否
Required<T>每个属性都变为必选,并去掉 undefined否
DeepPartial<T>(自己定义)每一层都可选是
PartialBy<T, K>(自己定义)只有键 K 变为可选否
Readonly<T>每个属性都变为 readonly否

常见问题

TypeScript 中的 Partial 有什么作用?

Partial<T> 会创建一个类型,把 T 的所有属性都标记为可选。对于 interface User { name: string; email: string },Partial<User> 就是 { name?: string; email?: string },所以 {}、{ name: "Ada" } 和完整的用户对象都是合法的值。

TypeScript 的 Partial 是深层的吗?

不是,Partial 只影响顶层属性。Partial<T> 里的嵌套对象仍然必须是完整的。如果需要递归版本,就写一个 DeepPartial<T> 类型,让它对对象类型的属性递归应用自身。

TypeScript 中 Partial 的反面是什么?

Required<T>。它去掉每个属性的 ?,同时从属性类型中去掉 undefined,所以 Required<{ port?: number }> 就是 { port: number }。它是用带 -? 修饰符的映射类型定义的。

如何只让部分属性变成可选?

组合使用 Omit、Pick 和 Partial:type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>。PartialBy<User, "email"> 保持其他属性不变,只有 email 变成可选。

为什么合并 Partial 更新之后,某个属性变成了 undefined?

Partial<T> 允许属性存在但值为 undefined,而对象展开会把它复制过去:{ ...user, ...{ name: undefined } } 的结果里是 name: undefined,尽管 TypeScript 把结果的类型标为 User。合并之前先过滤掉 undefined 值,或者开启 exactOptionalPropertyTypes,让显式的 undefined 被拒绝。

Coddy 编程语言插图

和 Coddy 一起学编程

开始