一个戴着兜帽的人物手中拿着一个玻璃小瓶,瓶中装满了深色、不断冒泡的液体,并散发出烟雾。
到目前为止,我们只是在对象字面量的上下文中讨论过对象类型,也就是使用 {} 并结合类型别名来定义对象类型。不过,TypeScript 还有很多其他工具,可以让你以更具表达力的方式处理对象类型。在本章中,你会学习几种处理对象的技术,它们会把语法和内置工具类型结合起来。
你可以使用交叉类型,把多个对象类型中的属性组合成一个单一类型。这个特性有点类似于在接口之间建模继承关系,不过其中也有一些细微差别需要注意。TypeScript 还提供了多种用于处理对象类型的工具类型,允许你快速转换现有类型以适配自己的需求;此外,它还提供了用于处理动态属性名的特殊语法。
扩展对象
我们先从研究如何在 TypeScript 中基于其他对象类型构建对象类型开始。随着应用增长,引入更多对象是很常见的事情。为了避免在代码中重复自己,你有多种选择可以从已有对象类型创建新的对象类型,包括交叉类型,以及在使用接口时使用 extends 关键字。
交叉类型
交叉类型使用 & 操作符,它允许你把多个对象类型组合成一个单一类型。你可以把它看成 | 操作符的反面。| 操作符表示类型之间的“或”关系,而 & 操作符表示“且”关系。
看看下面这两个类型:Album 和 SalesData:
type Album = {
title: string;
artist: string;
releaseYear: number;
};
type SalesData = {
unitsSold: number;
revenue: number;
};
单独来看,每个类型都表示一组不同的属性。SalesData 类型本身可以用来表示任何产品的销售数据,而使用 & 操作符创建交叉类型,则可以把这两个类型组合成一个单一类型,用来表示一张专辑的销售数据:
type AlbumSales = Album & SalesData;
现在,AlbumSales 类型要求对象同时包含来自 Album 和 SalesData 的所有属性:
const wishYouWereHereSales: AlbumSales = {
title: "Wish You Were Here",
artist: "Pink Floyd",
releaseYear: 1975,
unitsSold: 13000000,
revenue: 65000000,
};
在前面的例子中,wishYouWereHereSales 包含了所有必需属性。如果缺少其中任何一个属性,就无法满足 AlbumSales 类型的契约,TypeScript 会报错。
交叉两个以上的类型也是可以的。如果你想给 AlbumSales 类型添加一个 genre,可以把一个简单对象加入交叉类型:
type AlbumSales = Album & SalesData & {genre: string};
交叉类型也可以和原始类型一起使用,例如 string 和 number,不过这通常会产生一些奇怪的结果。例如,我们尝试把 string 和 number 做交叉:
type StringAndNumber = string & number;
你觉得 StringAndNumber 会是什么类型?它实际上是 never,因为 string 和 number 不能被组合在一起。虽然一个对象可以同时是 Album 和 SalesData,但在 JavaScript 中,不存在一个值既是字符串又是数字。
每当你交叉两个对象类型,而它们拥有不兼容的属性时,最终都会得到 never 类型:
type User1 = {
age: number;
};
type User2 = {
age: string;
};
type User = User1 & User2;
// 悬停在 User 上会显示:
type User = {
age: never;
};
在这个例子中,age 属性会解析为 never,因为一个单独的属性不可能同时既是数字又是字符串。
接口
到目前为止,我们只展示了如何使用 type 关键字来定义对象类型。有经验的 TypeScript 程序员可能已经在抓狂了:“为什么还不讲接口?”接口是 TypeScript 最著名的特性之一。它从 TypeScript 的早期版本就已经存在,并且被认为是这门语言的核心部分。
接口允许你使用一种与 type 略有不同的语法来声明对象类型。我们来比较一下语法:
type Album = {
title: string;
artist: string;
releaseYear: number;
};
interface Album {
title: string;
artist: string;
releaseYear: number;
}
语法看起来很相似,区别在于使用了 interface 关键字,并且没有等号。这会导致一个常见误解:认为它们可以互换使用。但它们并不能完全互换!接口和类型具有相当不同的能力。
interface 最强大的特性之一,是它能够扩展其他类型。这允许你创建新的接口,从已有接口中继承属性。
在下面这个例子中,你有一个基础的 Album 接口,它会被扩展成 StudioAlbum 和 LiveAlbum 接口,从而允许你为一张专辑提供更具体的细节:
interface Album {
title: string;
artist: string;
releaseYear: number;
}
interface StudioAlbum extends Album {
studio: string;
producer: string;
}
interface LiveAlbum extends Album {
concertVenue: string;
concertDate: Date;
}
使用 extends 创建新的接口,可以让你用清晰的继承关系创建更具体的专辑表示:
const americanBeauty: StudioAlbum = {
title: "American Beauty",
artist: "Grateful Dead",
releaseYear: 1970,
studio: "Wally Heider Studios",
producer: "Grateful Dead and Stephen Barncard",
};
const oneFromTheVault: LiveAlbum = {
title: "One from the Vault",
artist: "Grateful Dead",
releaseYear: 1991,
concertVenue: "Great American Music Hall",
concertDate: new Date("1975-08-13"),
};
在前面的例子中,可以很清楚地看到 americanBeauty 和 oneFromTheVault 如何分别与 StudioAlbum、LiveAlbum 以及基础的 Album 接口关联起来。
就像添加额外的 & 操作符可以把类型加入交叉类型一样,一个接口也可以通过逗号分隔的方式,扩展多个其他接口:
interface BonusConcertEdition extends StudioAlbum, LiveAlbum {
numberOfDiscs: number;
}
这里,BonusConcertEdition 接口会拥有 StudioAlbum 和 LiveAlbum 两个接口的所有属性。
交叉类型 vs. interface extends
现在,我们已经介绍了两种扩展对象类型的 TypeScript 语法:& 和 interface extends。那么,哪一种更好?你应该选择 interface extends,原因有两个。
合并不兼容类型时,错误提示更好
你前面已经看到,当你交叉两个具有不兼容属性的对象类型时,TypeScript 会把该属性解析为 never:
type User1 = {
age: number;
};
type User2 = {
age: string;
};
type User = User1 & User2;
而使用 interface extends 时,如果你试图用一个不兼容属性扩展某个接口,TypeScript 会直接报错:
interface User1 {
age: number;
}
// User 下面出现红色波浪线
interface User extends User1 {
age: string;
}
悬停在 User 上会显示一条错误信息:
Interface 'User' incorrectly extends interface 'User1'.
Types of property 'age' are incompatible.
这种行为和交叉类型不同。使用交叉类型时,错误会在你尝试访问 age 属性时才显示,而不是在定义它时显示。
更好的 TypeScript 性能
当你使用 TypeScript 时,类型性能应该被放在心里。在大型项目中,你定义类型的方式会极大影响 IDE 的响应速度,以及 tsc 检查代码所需的时间。
相比交叉类型,interface extends 对 TypeScript 性能更友好。使用交叉类型时,每次使用该交叉类型,TypeScript 都需要重新计算这个交叉结果。尤其是在处理复杂类型时,这可能会比较慢。
接口更快。TypeScript 可以基于接口名称缓存最终得到的类型。这意味着,如果你使用 interface extends,TypeScript 只需要计算一次类型,之后每次使用这个接口时都可以复用它。
总结一下,使用 interface extends 更有利于捕获错误,也更有利于 TypeScript 性能。这并不意味着你需要使用 interface 来定义所有对象类型,我们后面会讲到这一点。但如果你需要让一个对象类型扩展另一个对象类型,应尽可能使用 interface extends。
type vs. interface
既然你已经知道 interface extends 在扩展对象类型方面很好,一个自然的问题就出现了:你是否应该默认对所有类型都使用 interface?我们来看几个 type 和 interface 之间的比较点。
类型别名比接口灵活得多。一个 type 可以表示任何东西:联合类型、对象类型、交叉类型等等:
type Union = string | number;
当你声明一个类型别名时,你只是给一个已有类型起了一个名字,或者说起了一个别名。另一方面,接口只能表示对象类型和函数。
接口还有另一个奇怪的特性。当在同一个作用域中创建多个同名接口时,TypeScript 会自动合并它们。这被称为声明合并。
假设你创建了一个 Album 接口,里面有 title 和 artist 属性。现在想象一下,在同一个文件的更下面,你不小心又声明了另一个 Album 接口,里面有 releaseYear 和 genres 属性:
interface Album {
title: string;
artist: string;
}
interface Album {
releaseYear: number;
genres: string[];
}
TypeScript 会自动把这两个声明合并成一个接口,其中包含两个声明中的所有属性:
// 幕后效果:
interface Album {
title: string;
artist: string;
releaseYear: number;
genres: string[];
}
由于声明合并,TypeScript 现在会期望 Album 接口包含 title、artist、releaseYear 和 genres 属性。这和 type 非常不同。如果你试图声明同一个类型两次,type 会直接报错:
// 两个 Album 下面都会出现红色波浪线
// duplicate identifier Album
type Album = {
title: string;
artist: string;
};
type Album = {
releaseYear: number;
genres: string[];
};
从 JavaScript 的角度来看,接口的这种行为会让人觉得相当奇怪。我们曾经因为在同一个 2000 多行的文件里有两个同名接口,而浪费过很多小时。这有点像一个坑,但这个特性存在也有充分理由,我们会在第 9 章进一步讲到。
这里的大问题是:声明简单对象类型时,你应该使用 type 还是 interface?我们倾向于默认使用 type,除非明确需要使用 interface extends。使用 type 更灵活,也不会意外发生声明合并。但这不是一个绝对答案。许多来自更偏面向对象背景的人,会更喜欢 interface,因为它在其他语言中对他们来说更熟悉。
练习 6-1:创建交叉类型
这里有一个 User 类型和一个 Product 类型,它们都有一些公共属性,比如 id 和 createdAt:
type User = {
id: string;
createdAt: Date;
name: string;
email: string;
};
type Product = {
id: string;
createdAt: Date;
name: string;
price: number;
};
你的任务是创建一个新的 BaseEntity 类型,其中包含 id 和 createdAt 属性。然后,使用 & 操作符创建与 BaseEntity 交叉的 User 和 Product 类型。
解决方案
为了解决这个挑战,我们先创建一个包含公共属性的新 BaseEntity 类型:
type BaseEntity = {
id: string;
createdAt: Date;
};
创建好 BaseEntity 类型之后,就可以把它和 User、Product 类型做交叉:
type User = {
id: string;
createdAt: Date;
name: string;
email: string;
} & BaseEntity;
type Product = {
id: string;
createdAt: Date;
name: string;
price: number;
} & BaseEntity;
然后,你可以从 User 和 Product 中移除公共属性:
type User = {
name: string;
email: string;
} & BaseEntity;
type Product = {
name: string;
price: number;
} & BaseEntity;
现在,User 和 Product 的行为和之前一样,但重复代码更少了。
练习 6-2:扩展接口
完成上一个练习后,你会有一个 BaseEntity 类型,以及与它交叉的 User 和 Product 类型。这一次,你的任务是把这些类型重构为接口,并使用 extends 关键字扩展 BaseEntity 类型。额外加分:尝试创建多个更小的接口并扩展它们。
解决方案
不要使用 type 关键字,BaseEntity、User 和 Product 可以声明为接口。记住,接口不像 type 那样使用等号:
interface BaseEntity {
id: string;
createdAt: Date;
}
interface User {
name: string;
email: string;
}
interface Product {
name: string;
price: number;
}
创建好接口之后,你可以使用 extends 关键字扩展 BaseEntity 接口:
interface User extends BaseEntity {
name: string;
email: string;
}
interface Product extends BaseEntity {
name: string;
price: number;
}
额外加分的做法是进一步创建 WithId 和 WithCreatedAt 接口,它们分别表示带有 id 和 createdAt 属性的对象。然后,你可以通过添加逗号,让 User 和 Product 从这些接口扩展:
interface WithId {
id: string;
}
interface WithCreatedAt {
createdAt: Date;
}
interface User extends WithId, WithCreatedAt {
name: string;
email: string;
}
interface Product extends WithId, WithCreatedAt {
name: string;
price: number;
}
现在,你已经把交叉类型重构成了 interface extends,你的 TypeScript 编译器会感谢你。
动态对象键
使用对象时,你常常并不知道具体会用到哪些键。这可能发生在从 API 获取数据时,也可能发生在处理用户输入时,甚至可能发生在创建你自己的对象时。TypeScript 给了你几种不同方式,在保持类型安全的同时支持动态对象键,包括索引签名、用于表达键值关系的 Record 工具类型,以及 PropertyKey 类型。我们先从比较 JavaScript 和 TypeScript 的行为开始。
在 JavaScript 中,你可以从一个空对象开始,然后动态地向它添加键和值:
// JavaScript 示例
const albumAwards = {};
albumAwards.Grammy = true;
albumAwards.MercuryPrize = false;
albumAwards.Billboard = true;
这里,albumAwards 一开始是空对象,然后再添加 Grammy、MercuryPrize 等键和值,并没有任何问题。不过,当你在 TypeScript 中尝试向对象动态添加键时,就会得到错误:
// TypeScript 示例
const albumAwards = {};
albumAwards.Grammy = true; // Grammy 下面出现红色波浪线
albumAwards.MercuryPrize = false; // MercuryPrize 下面出现红色波浪线
albumAwards.Billboard = true; // Billboard 下面出现红色波浪线
// 悬停在 Grammy 上会显示:
Property 'Grammy' does not exist on type '{}'.
这可能让人觉得不太友好。你可能会想,基于 TypeScript 收窄代码的能力,它应该能够弄清楚你正在向对象添加键。在这个例子中,TypeScript 更倾向于保守。它不会允许你向一个它不知道的对象添加键,因为 TypeScript 试图防止你犯错。你需要告诉 TypeScript,你希望能够动态添加键。我们来看几种实现方式。
索引签名
我们回到前面的例子:
const albumAwards = {};
albumAwards.Grammy = true; // Grammy 下面出现红色波浪线
你在这里做的事情,技术术语叫作索引。你正在用一个字符串键 Grammy 索引进 albumAwards,并给它赋值。为了支持这种行为,你需要告诉 TypeScript:每当你试图用字符串索引进 albumAwards 时,应该预期得到一个布尔值。为此,你可以使用索引签名。
下面是如何为 albumAwards 对象指定索引签名:
const albumAwards: {
[index: string]: boolean;
} = {};
albumAwards.Grammy = true;
albumAwards.MercuryPrize = false;
albumAwards.Billboard = true;
[index: string]: boolean 语法就是索引签名。它告诉 TypeScript:albumAwards 可以拥有任意字符串键,并且值始终是布尔值。
你可以为这个索引选择任何名称。它只是一个描述:
const albumAwards: {
[iCanBeAnything: string]: boolean;
} = {};
你也可以在 type 和 interface 中使用同样的语法:
interface AlbumAwards {
[index: string]: boolean;
}
const beyonceAwards: AlbumAwards = {
Grammy: true,
Billboard: true,
};
索引签名是处理动态键的一种方式,但还有一个工具类型,有些人认为它甚至更好。
Record 类型
Record 工具类型是支持动态键的另一个选项。
下面展示了如何为 albumAwards 对象使用 Record,其中键是字符串,值是布尔值:
const albumAwards: Record<string, boolean> = {};
albumAwards.Grammy = true;
第一个类型参数是键,第二个类型参数是值。相比索引签名,这是实现类似结果的一种更简洁方式。
Record 类型还可以支持联合类型作为键,而索引签名不行:
const albumAwards: Record<"Grammy" | "MercuryPrize" | "Billboard", boolean> = {
Grammy: true,
MercuryPrize: false,
Billboard: true,
};
const albumAwardsWithIndexSignature: {
// index 下面出现红色波浪线
[index: "Grammy" | "MercuryPrize" | "Billboard"]: boolean;
} = {
Grammy: true,
MercuryPrize: false,
Billboard: true,
};
// 悬停在 index 上会显示:
An index signature parameter type cannot be a literal type or generic type.
Consider using a mapped object type instead.
正如 albumAwardsWithIndexSignature 的错误信息所示,不能把可能字符串的联合类型传给索引签名。这是因为索引签名不支持字面量类型。我们会在第 15 章讲映射类型时解释原因。
Record 类型助手足够灵活,可以支持字面量类型,并且提供了一个易读、易理解、可重复使用的模式。它应该成为你处理动态键时的首选。
已知键与动态键的组合
在很多情况下,你会知道自己想包含一组基础键,但同时也希望允许额外的键被动态添加。例如,假设你正在处理一组你知道是提名的基础奖项,但你不知道还会涉及哪些其他奖项。你可以使用 Record 类型定义一组基础奖项,然后用交叉类型结合一个索引签名来扩展它,使其支持额外奖项:
type BaseAwards = "Grammy" | "MercuryPrize" | "Billboard";
type ExtendedAlbumAwards = Record<BaseAwards, boolean> & {
[award: string]: boolean;
};
const extendedNominations: ExtendedAlbumAwards = {
Grammy: true,
MercuryPrize: false,
Billboard: true, // 可以动态添加额外奖项。
"American Music Awards": true,
};
在前面的例子中,ExtendedAlbumAwards 类型组合了一个已知奖项的 Record,同时也支持动态添加其他未知奖项。使用接口和 extends 关键字时,同样的技术也适用:
interface BaseAwards {
Grammy: boolean;
MercuryPrize: boolean;
Billboard: boolean;
}
interface ExtendedAlbumAwards extends BaseAwards {
[award: string]: boolean;
}
虽然 type 和 interface 两种示例都能实现已知键和动态键的组合,但在这个场景下,interface extends 比交叉类型更可取。在你的数据结构中同时支持默认键和动态键,可以提供很大的灵活性,让你的应用更容易适应不断变化的需求。
PropertyKey 类型
处理动态键时,一个很有用的类型是 PropertyKey。这个全局类型表示一个对象上所有可能使用的键的集合,包括 string、number 和 symbol。你可以在 TypeScript 的 ES5 类型定义文件中找到它的类型定义。我们会在第 13 章更仔细地讲这个文件:
// 在 lib.es5.d.ts 中
declare type PropertyKey = string | number | symbol;
由于 PropertyKey 可以处理所有可能的键,所以当你不确定键的类型会是什么时,它非常适合用来处理动态键。例如,在使用索引签名时,你可以把键类型设置为 PropertyKey,以允许任何有效键类型:
type Album = {
[key: PropertyKey]: string;
};
练习 6-3:使用索引签名处理动态键
这里有一个名为 scores 的对象,你正试图给它赋几个不同的属性:
const scores = {};
scores.math = 95; // math 下面出现红色波浪线
scores.english = 90; // english 下面出现红色波浪线
scores.science = 85; // science 下面出现红色波浪线
你的任务是给 scores 添加一个类型注解,以支持动态的学科键。这里有四种方式可以做到:内联索引签名、type、interface,或者 Record。
解决方案
这个练习有四种解决方案。
你可以使用内联索引签名:
const scores: {
[key: string]: number;
} = {};
你可以使用接口:
interface Scores {
[key: string]: number;
}
你可以使用类型:
type Scores = {
[key: string]: number;
};
最后,你可以使用 Record:
const scores: Record<string, number> = {};
练习 6-4:带动态键的默认属性
在这个练习中,你试图建模这样一种情况:你希望 Scores 对象上有一些必需键,也就是 math、english 和 science;但同时也希望可以添加动态属性,在这个例子中是 athletics、french 和 spanish:
interface Scores {}
// @ts-expect-error science should be provided // @ts-expect-error 下面出现红色波浪线
const scores: Scores = {
math: 95,
english: 90,
};
scores.athletics = 100; // athletics 下面出现红色波浪线
scores.french = 75; // french 下面出现红色波浪线
scores.spanish = 70; // spanish 下面出现红色波浪线
Scores 的定义应该报错,因为缺少 science,但现在并没有报错,因为你的 Scores 定义目前是一个空对象。
你的任务是更新 Scores 接口,为 math、english 和 science 指定默认键,同时允许添加任何其他学科。正确更新类型后,@ts-expect-error 下面的红色波浪线会消失,因为 science 会变成必需属性,但它确实缺失了。尝试使用 interface extends 来实现这一点。
解决方案
下面是如何向 Scores 接口添加索引签名,同时支持动态键和必需键:
interface Scores {
[subject: string]: number;
math: number;
english: number;
science: number;
}
创建一个 RequiredScores 接口并扩展它,看起来是这样:
interface RequiredScores {
math: number;
english: number;
science: number;
}
interface Scores extends RequiredScores {
[key: string]: number;
}
这两个解决方案在功能上是等价的,区别只是如果你之后需要单独使用 RequiredScores 接口,第二种方式会让你可以访问它。
练习 6-5:使用 Record 限制对象键
在这个例子中,你有一个 configurations 对象,它被标注为 Configurations 类型,而当前 Configurations 是 unknown。这个对象保存了 development、production 和 staging 对应的键,每个键都关联着配置详情,例如 apiBaseUrl 和 timeout。这里还有一个 notAllowed 键,它带有一个 @ts-expect-error 注释,但目前 TypeScript 并没有像预期那样报错:
type Environment = "development" | "production" | "staging";
type Configurations = unknown;
const configurations: Configurations = {
development: {
apiBaseUrl: "http://localhost:8080",
timeout: 5000,
},
production: {
apiBaseUrl: "https://api.example.com",
timeout: 10000,
},
staging: {
apiBaseUrl: "https://staging.example.com",
timeout: 8000,
},
// @ts-expect-error // @ts-expect-error 下面出现红色波浪线
notAllowed: {
apiBaseUrl: "https://staging.example.com",
timeout: 8000,
},
};
请更新 Configurations 类型,使 configurations 对象上只允许来自 Environment 的键。正确更新类型后,@ts-expect-error 下面的红色波浪线会消失,因为 notAllowed 会被正确禁止。
解决方案
首先,我们考虑一次使用 Record 的尝试。你知道 configurations 对象的值会包含 apiBaseUrl,它是字符串;以及 timeout,它是数字。你可能会想使用 Record,把键设置为字符串,把值设置为一个带有 apiBaseUrl 和 timeout 属性的对象:
type Configurations = Record<
string,
{
apiBaseUrl: string;
timeout: number;
}
>;
不过,把键设置为 string 仍然允许把 notAllowed 键添加到对象中。你需要让这些键依赖于 Environment 类型。
因此,正确方式是在 Record 中把键指定为 Environment:
type Configurations = Record<
Environment,
{
apiBaseUrl: string;
timeout: number;
}
>;
现在,当对象包含一个不存在于 Environment 中的键时,比如 notAllowed,TypeScript 就会抛出错误。
练习 6-6:动态键支持
看看这个 hasKey 函数,它接收一个对象和一个键,然后在该对象上调用 object.hasOwnProperty:
const hasKey = (obj: object, key: string) => {
return obj.hasOwnProperty(key);
};
注意,object 类型表示的不只是对象字面量;它表示任何非原始类型,包括数组和函数。
hasKey 函数有几个测试用例。
第一个测试用例检查它是否适用于字符串键,这没有任何问题。正如预期,hasKey(obj, "foo") 会返回 true,而 hasKey(obj, "bar") 会返回 false:
it("Should work on string keys", () => {
const obj = {
foo: "bar",
};
expect(hasKey(obj, "foo")).toBe(true);
expect(hasKey(obj, "bar")).toBe(false);
});
检查数字键的测试用例会有问题,因为函数期望的是字符串键:
it("Should work on number keys", () => {
const obj = {
1: "bar",
};
expect(hasKey(obj, 1)).toBe(true); // 1 下面出现红色波浪线
expect(hasKey(obj, 2)).toBe(false); // 2 下面出现红色波浪线
});
由于对象也可以使用 symbol 作为键,所以这里还有一个针对这种情况的测试。目前,在调用 hasKey 时,fooSymbol 和 barSymbol 都有类型错误:
it("Should work on symbol keys", () => {
const fooSymbol = Symbol("foo");
const barSymbol = Symbol("bar");
const obj = {
[fooSymbol]: "bar",
};
expect(hasKey(obj, fooSymbol)).toBe(true); // fooSymbol 下面出现红色波浪线
expect(hasKey(obj, barSymbol)).toBe(false); // barSymbol 下面出现红色波浪线
});
你的任务是更新 hasKey 函数,让所有这些测试都通过。尽量保持简洁!
解决方案
显而易见的答案是把 key 的类型改成 string | number | symbol:
const hasKey = (obj: object, key: string | number | symbol) => {
return obj.hasOwnProperty(key);
};
不过,还有一个更加简洁的解决方案。悬停在 hasOwnProperty 上,会看到它的类型定义:
(method) Object.hasOwnProperty(v: PropertyKey): boolean
回想一下,PropertyKey 类型表示一个键可以拥有的所有可能值。这意味着你可以把它作为 key 参数的类型:
const hasKey = (obj: object, key: PropertyKey) => {
return obj.hasOwnProperty(key);
};
漂亮。
使用工具类型减少重复
我们已经描述了如何使用 interface extends 来帮助你建模继承,但 TypeScript 也提供了一些工具,可以直接操作对象类型。借助它的内置工具类型,你可以从类型中移除属性,把属性变成可选或必需,等等。
Partial 类型
Partial 工具类型允许你从一个已有对象类型创建一个新对象类型,只不过新类型中的所有属性都是可选的。
考虑一个包含专辑详细信息的 Album 接口:
interface Album {
title: string;
artist: string;
releaseYear: number;
genre: string;
}
当你想更新一张专辑的信息时,可能不会一次性拥有所有信息。例如,在专辑发布之前,很难决定应该给它分配什么流派。使用 Partial 工具类型,并传入 Album,你可以创建一个类型,它允许你更新专辑属性的任意子集:
type PartialAlbum = Partial<Album>;
现在你有了一个 PartialAlbum 类型,其中 title、artist、releaseYear 和 genre 都是可选的。这意味着你可以创建一个函数,它只接收专辑属性的一部分:
const updateAlbum = (album: PartialAlbum) => {
// ...
};
updateAlbum({title: "Geogaddi", artist: "Boards of Canada"});
在这个例子中,得益于使用了 Partial 类型助手,即使缺少 id、releaseYear 或 genre 属性,TypeScript 也不会抱怨。
Required 类型
与 Partial 相反的是 Required 类型,它会确保给定对象类型的所有属性都是必需的。
下面这个 Album 接口中,releaseYear 和 genre 属性被标记为可选:
interface Album {
title: string;
artist: string;
releaseYear?: number;
genre?: string;
}
你可以使用 Required 工具类型创建一个新的 RequiredAlbum 类型:
type RequiredAlbum = Required<Album>;
有了 RequiredAlbum 之后,原始 Album 的所有属性都会变成必需属性,省略其中任何一个都会导致错误:
const doubleCup: RequiredAlbum = {
title: "Double Cup",
artist: "DJ Rashad",
releaseYear: 2013,
genre: "Juke",
};
doubleCup 示例定义了原始 Album 接口的所有属性,所以没有错误。如果它缺少 releaseYear 或 genre,TypeScript 就会显示错误。
需要注意的是,Required 和 Partial 都只作用于一层。例如,如果 Album 的 genre 包含嵌套属性,Required<Album> 并不会让它的子属性也变成必需:
type Album = {
title: string;
artist: string;
releaseYear?: number;
genre?: {
parentGenre?: string;
subGenre?: string;
};
};
type RequiredAlbum = Required<Album>;
// 悬停在 RequiredAlbum 上会显示:
type RequiredAlbum = {
title: string;
artist: string;
releaseYear: number;
genre: {
parentGenre?: string;
subGenre?: string;
};
};
在前面的代码片段中,即使使用了 Required 类型助手,parentGenre 和 subGenre 属性仍然是可选的。
注意
如果你发现自己需要一个深层的 Required 类型,可以看看 Sindre Sorhus 的 type-fest 库:github.com/sindresorhu…。
Pick 类型
Pick 工具类型允许你从已有对象中挑选某些属性,创建一个新的对象类型。
例如,假设你想创建一个新类型,它只包含 Album 类型中的 title 和 artist 属性:
type AlbumData = Pick<Album, "title" | "artist">;
这会得到一个只包含这些属性的 AlbumData 类型。当你希望一个对象依赖另一个对象的形状时,这个工具非常有用。我们会在第 10 章进一步探索这一点。
Omit 类型
Omit 工具类型有点像 Pick 的反面。它允许你通过从已有类型中排除一部分属性来创建一个新类型。
例如,你可以使用 Omit 创建和前面用 Pick 创建出来一样的 AlbumData 类型,只不过这次是通过排除 id、releaseYear 和 genre 属性来实现:
type AlbumData = Omit<Album, "id" | "releaseYear" | "genre">;
一个常见使用场景是创建一个不带 id 的类型,用于那些 id 还没有被分配的情况:
type AlbumData = Omit<Album, "id">;
这意味着,随着 Album 获得更多属性,这些属性也会向下流入 AlbumData。
表面上看,使用 Omit 很直接,但这里有一个小怪癖需要注意。使用 Omit 时,你可以排除对象类型上并不存在的属性。例如,使用你的 Album 类型创建一个 AlbumWithoutProducer 类型不会导致错误,即使 producer 并不存在于 Album 上:
type Album = {
id: string;
title: string;
artist: string;
releaseYear: number;
genre: string;
};
type AlbumWithoutProducer = Omit<Album, "producer">;
如果你尝试使用 Pick 创建一个 AlbumWithOnlyProducer 类型,就会得到错误,因为 producer 不存在于 Album 上:
type AlbumWithOnlyProducer = Pick<Album, "producer">; // "producer" 下面出现红色波浪线
// 悬停在 "producer" 上会显示:
Type '"producer"' does not satisfy the constraint 'keyof Album'.
为什么这两个工具类型表现不同?当 TypeScript 团队最初实现 Omit 时,他们面临一个选择:创建严格版本还是宽松版本。严格版本只允许省略有效键,也就是 id、title、artist、releaseYear 和 genre;而宽松版本没有这个约束。当时,在社区里更受欢迎的想法是实现一个宽松版本,所以团队最终选择了这种方式。鉴于 TypeScript 中的全局类型是全局可用的,不需要 import 语句,因此更宽松的版本被视为更安全的选择,因为它兼容性更好,也更不容易造成意外错误。
这个宽松版本的 Omit 对大多数情况来说已经足够了。只是要留心,因为它可能会以你意想不到的方式报错。我们会在第 15 章看一个严格版本的 Omit。
注意
如果想进一步了解 Omit 背后的决策,可以参考 TypeScript 团队最初的讨论:github.com/microsoft/T…,以及添加 Omit 的 pull request:github.com/microsoft/T…,还有他们关于这个话题的最终说明:github.com/microsoft/T…。
联合类型中的 Omit 和 Pick
当 Omit 和 Pick 类型与联合类型一起使用时,会表现出一些奇怪行为。例如,考虑这样一个场景:你有三个接口类型,分别是 Album、CollectorEdition 和 DigitalRelease:
type Album = {
id: string;
title: string;
genre: string;
};
type CollectorEdition = {
id: string;
title: string;
limitedEditionFeatures: string[];
};
type DigitalRelease = {
id: string;
title: string;
digitalFormat: string;
};
这些类型共享两个公共属性:id 和 title,但每个类型也都有自己独有的属性。Album 类型包含 genre,CollectorEdition 包含 limitedEditionFeatures,而 DigitalRelease 包含 digitalFormat。
创建一个由这三种类型组成的联合类型 MusicProduct 之后,假设你想创建一个 MusicProductWithoutId 类型,它镜像 MusicProduct 的结构,但排除 id 字段:
type MusicProduct = Album | CollectorEdition | DigitalRelease;
type MusicProductWithoutId = Omit<MusicProduct, "id">;
你可能会以为 MusicProductWithoutId 会是去掉 id 字段后的三个类型组成的联合。但你实际得到的是一个被简化后的对象类型,其中只包含 title:
// 预期:
type MusicProductWithoutId =
| Omit<Album, "id">
| Omit<CollectorEdition, "id">
| Omit<DigitalRelease, "id">;
// 实际:
type MusicProductWithoutId = {
title: string;
};
原因是,id 和 title 是联合类型所有成员唯一共享的属性。所以,Omit 看起来会先把联合类型折叠成它的公共属性,也就是 id 和 title,然后再移除 id。
这一点特别烦人,因为 Partial 和 Required 在联合类型上可以按预期工作:
type PartialMusicProduct = Partial<MusicProduct>;
// 悬停在 PartialMusicProduct 上会显示:
type PartialMusicProduct =
| Partial<Album>
| Partial<CollectorEdition>
| Partial<DigitalRelease>;
这个问题来自 Omit 处理联合类型的方式。它不会遍历每个联合成员,而是把它们合并成一个它能理解的单一结构。技术原因是,Omit 和 Pick 不是分布式的,也就是说,当你把它们用于联合类型时,它们不会分别作用于联合类型的每个成员。
为了解决这个问题,你可以创建一个 DistributiveOmit 类型。它的定义类似于 Omit,但会分别作用于每个联合成员。注意,类型定义中包含了 PropertyKey,以允许任何有效键类型:
type DistributiveOmit<T, K extends PropertyKey> = T extends any
? Omit<T, K>
: never;
当你把 DistributiveOmit 应用于 MusicProduct 类型时,会得到预期结果:一个由省略 id 字段后的 Album、CollectorEdition 和 DigitalRelease 组成的联合类型:
type MusicProductWithoutId = DistributiveOmit<MusicProduct, "id">;
// 悬停在 MusicProductWithoutId 上会显示:
type MusicProductWithoutId =
| Omit<Album, "id">
| Omit<CollectorEdition, "id">
| Omit<DigitalRelease, "id">;
从结构上看,它等同于下面这样:
type MusicProductWithoutId =
| {
title: string;
genre: string;
}
| {
title: string;
limitedEditionFeatures: string[];
}
| {
title: string;
digitalFormat: string;
};
在你需要对联合类型使用 Omit 的场景中,使用分布式版本会得到更可预测的结果。
为了完整起见,DistributivePick 类型也可以用类似方式定义:
type DistributivePick<T, K extends keyof T> = T extends any
? Pick<T, K>
: never;
这个 DistributivePick 类型会分别对联合类型的每个成员应用 Pick 操作。
练习 6-7:期望某些属性
在这个练习中,你有一个 fetchUser 函数,它使用 fetch 访问名为 api/user 的端点,并返回一个 Promise<User>:
interface User {
id: string;
name: string;
email: string;
role: string;
}
const fetchUser = async (): Promise<User> => {
const response = await fetch("/api/user");
const user = await response.json();
return user;
};
const example = async () => {
const user = await fetchUser();
type test = Expect<Equal<typeof user, {name: string; email: string}>>;
// Equal<> 下面出现红色波浪线
};
由于你处在一个异步函数中,所以确实需要使用 Promise,但这个 User 类型存在一个问题。在调用 fetchUser 的 example 函数中,你期望只收到 name 和 email 字段。这些字段只是 User 接口中已有内容的一部分。
你的任务是更新类型,使 fetchUser 只被期望返回 name 和 email 字段。你可以使用前面看过的工具类型完成这个任务;不过为了额外练习,也可以尝试只使用接口。
解决方案
这个问题有很多种解决方式。使用 Pick 工具类型,你可以创建一个只包含 User 接口中 name 和 email 属性的新类型:
type PickedUser = Pick<User, "name" | "email">;
然后,可以更新 fetchUser 函数,让它返回一个 PickedUser 的 Promise:
const fetchUser = async (): Promise<PickedUser> => {
// ...
你也可以使用 Omit 工具类型创建一个新类型,从 User 接口中排除 id 和 role 属性:
type OmittedUser = Omit<User, "id" | "role">;
然后,可以更新 fetchUser 函数,让它返回一个 OmittedUser 的 Promise:
const fetchUser = async (): Promise<OmittedUser> => {
// ...
另一种可能是创建一个 NameAndEmail 接口,其中包含 name 属性和 email 属性,并更新 User 接口,移除这些属性,改为扩展它们:
interface NameAndEmail {
name: string;
email: string;
}
interface User extends NameAndEmail {
id: string;
role: string;
}
然后,fetchUser 函数可以返回一个 NameAndEmail 的 Promise:
const fetchUser = async (): Promise<NameAndEmail> => {
// ...
};
使用 Omit 意味着随着源对象增长,目标对象也会一起增长;而使用 Pick 和 interface extends 则意味着目标对象会保持相同大小。你应该根据自己的需求选择最合适的方法。
练习 6-8:更新产品
这里有一个函数 updateProduct,它接收两个参数:一个 id,以及一个从 Product 类型派生出来、排除了 id 字段的 productInfo 对象:
interface Product {
id: number;
name: string;
price: number;
description: string;
}
const updateProduct = (id: number, productInfo: Omit<Product, "id">) => {
// 对 productInfo 做一些处理。
};
这里的转折点是,在更新产品时,你可能并不想同时修改它的所有属性。因此,并不需要把所有属性都传给这个函数。
这意味着你有几个不同的测试场景。例如,只更新名称,只更新价格,或者只更新描述。组合情况也会被测试,例如更新名称和价格,或者更新名称和描述:
updateProduct(1, {
// 整个对象下面出现红色波浪线
name: "Book",
});
updateProduct(1, {
// 整个对象下面出现红色波浪线
price: 12.99,
});
updateProduct(1, {
// 整个对象下面出现红色波浪线
description: "A book about Dragons",
});
updateProduct(1, {
// 整个对象下面出现红色波浪线
name: "Book",
price: 12.99,
});
updateProduct(1, {
// 整个对象下面出现红色波浪线
name: "Book",
description: "A book about Dragons",
});
你的挑战是修改 productInfo 参数,使其反映这些需求。id 应该仍然不存在于 productInfo 中,但你也希望 productInfo 中的所有其他属性都是可选的。
解决方案
结合使用 Omit 和 Partial,可以创建一个类型:它从 Product 中排除 id 字段,并让所有其他属性都变成可选。
在这个例子中,把 Omit<Product, "id"> 包在 Partial 中,就会移除 id,同时让所有剩余属性变成可选:
const updateProduct = (
id: number,
productInfo: Partial<Omit<Product, "id">>,
) => {
// 对 productInfo 做一些处理。
};
总结
本章探索了 TypeScript 中用于处理对象类型的强大特性,重点是扩展对象以及管理动态属性。
TypeScript 提供了两种主要方式来扩展对象类型:使用 & 操作符的交叉类型,以及使用 extends 关键字的接口扩展。交叉类型会把多个对象类型组合成一个,而 interface extends 会在接口之间创建继承关系。
相比交叉类型,interface extends 有显著优势:当属性冲突时,它能提供更好的错误信息;同时还能带来更好的 TypeScript 性能,因为接口可以按名称缓存,而不需要每次都重新计算。
在为对象定义选择 type 还是 interface 时,类型别名提供了更高灵活性,可以支持联合类型和其他复杂类型。不过,接口有一个怪癖:声明合并。多个同名接口会自动合并它们的属性。
对于动态对象键,TypeScript 提供了几种解决方案。使用 [key: string]: valueType 语法的索引签名,允许对象接受任意字符串键。Record<KeyType, ValueType> 工具类型提供了一种更干净的替代方案,并且和索引签名不同,它支持联合类型作为键。
你可以通过把 Record 类型与索引签名做交叉,或者通过扩展一个同时包含具体属性和索引签名的接口,来组合已知键和动态键。全局的 PropertyKey 类型表示所有可能的键类型:string、number 和 symbol。
TypeScript 的内置工具类型有助于减少代码重复。Partial<T> 会让所有属性变成可选,而 Required<T> 会让所有属性变成必需。Pick<T, K> 会选择特定属性,Omit<T, K> 则会排除它们。
当 Omit 和 Pick 与联合类型一起使用时,它们不会像预期那样分布到联合类型的每个成员上。使用条件类型创建分布式版本,可以解决这个限制,确保这些工具在联合类型上也能以可预测的方式工作。