全栈 TypeScript——对象

12 阅读31分钟

一个戴着兜帽的人物手中拿着一个玻璃小瓶,瓶中装满了深色、不断冒泡的液体,并散发出烟雾。

到目前为止,我们只是在对象字面量的上下文中讨论过对象类型,也就是使用 {} 并结合类型别名来定义对象类型。不过,TypeScript 还有很多其他工具,可以让你以更具表达力的方式处理对象类型。在本章中,你会学习几种处理对象的技术,它们会把语法和内置工具类型结合起来。

你可以使用交叉类型,把多个对象类型中的属性组合成一个单一类型。这个特性有点类似于在接口之间建模继承关系,不过其中也有一些细微差别需要注意。TypeScript 还提供了多种用于处理对象类型的工具类型,允许你快速转换现有类型以适配自己的需求;此外,它还提供了用于处理动态属性名的特殊语法。

扩展对象

我们先从研究如何在 TypeScript 中基于其他对象类型构建对象类型开始。随着应用增长,引入更多对象是很常见的事情。为了避免在代码中重复自己,你有多种选择可以从已有对象类型创建新的对象类型,包括交叉类型,以及在使用接口时使用 extends 关键字。

交叉类型

交叉类型使用 & 操作符,它允许你把多个对象类型组合成一个单一类型。你可以把它看成 | 操作符的反面。| 操作符表示类型之间的“或”关系,而 & 操作符表示“且”关系。

看看下面这两个类型:AlbumSalesData

type Album = {
  title: string;
  artist: string;
  releaseYear: number;
};

type SalesData = {
  unitsSold: number;
  revenue: number;
};

单独来看,每个类型都表示一组不同的属性。SalesData 类型本身可以用来表示任何产品的销售数据,而使用 & 操作符创建交叉类型,则可以把这两个类型组合成一个单一类型,用来表示一张专辑的销售数据:

type AlbumSales = Album & SalesData;

现在,AlbumSales 类型要求对象同时包含来自 AlbumSalesData 的所有属性:

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};

交叉类型也可以和原始类型一起使用,例如 stringnumber,不过这通常会产生一些奇怪的结果。例如,我们尝试把 stringnumber 做交叉:

type StringAndNumber = string & number;

你觉得 StringAndNumber 会是什么类型?它实际上是 never,因为 stringnumber 不能被组合在一起。虽然一个对象可以同时是 AlbumSalesData,但在 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 接口,它会被扩展成 StudioAlbumLiveAlbum 接口,从而允许你为一张专辑提供更具体的细节:

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"),
};

在前面的例子中,可以很清楚地看到 americanBeautyoneFromTheVault 如何分别与 StudioAlbumLiveAlbum 以及基础的 Album 接口关联起来。

就像添加额外的 & 操作符可以把类型加入交叉类型一样,一个接口也可以通过逗号分隔的方式,扩展多个其他接口:

interface BonusConcertEdition extends StudioAlbum, LiveAlbum {
  numberOfDiscs: number;
}

这里,BonusConcertEdition 接口会拥有 StudioAlbumLiveAlbum 两个接口的所有属性。

交叉类型 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?我们来看几个 typeinterface 之间的比较点。

类型别名比接口灵活得多。一个 type 可以表示任何东西:联合类型、对象类型、交叉类型等等:

type Union = string | number;

当你声明一个类型别名时,你只是给一个已有类型起了一个名字,或者说起了一个别名。另一方面,接口只能表示对象类型和函数。

接口还有另一个奇怪的特性。当在同一个作用域中创建多个同名接口时,TypeScript 会自动合并它们。这被称为声明合并。

假设你创建了一个 Album 接口,里面有 titleartist 属性。现在想象一下,在同一个文件的更下面,你不小心又声明了另一个 Album 接口,里面有 releaseYeargenres 属性:

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 接口包含 titleartistreleaseYeargenres 属性。这和 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 类型,它们都有一些公共属性,比如 idcreatedAt

type User = {
  id: string;
  createdAt: Date;
  name: string;
  email: string;
};

type Product = {
  id: string;
  createdAt: Date;
  name: string;
  price: number;
};

你的任务是创建一个新的 BaseEntity 类型,其中包含 idcreatedAt 属性。然后,使用 & 操作符创建与 BaseEntity 交叉的 UserProduct 类型。

参见:totalts.link/essentials-…

解决方案

为了解决这个挑战,我们先创建一个包含公共属性的新 BaseEntity 类型:

type BaseEntity = {
  id: string;
  createdAt: Date;
};

创建好 BaseEntity 类型之后,就可以把它和 UserProduct 类型做交叉:

type User = {
  id: string;
  createdAt: Date;
  name: string;
  email: string;
} & BaseEntity;

type Product = {
  id: string;
  createdAt: Date;
  name: string;
  price: number;
} & BaseEntity;

然后,你可以从 UserProduct 中移除公共属性:

type User = {
  name: string;
  email: string;
} & BaseEntity;

type Product = {
  name: string;
  price: number;
} & BaseEntity;

现在,UserProduct 的行为和之前一样,但重复代码更少了。

练习 6-2:扩展接口

完成上一个练习后,你会有一个 BaseEntity 类型,以及与它交叉的 UserProduct 类型。这一次,你的任务是把这些类型重构为接口,并使用 extends 关键字扩展 BaseEntity 类型。额外加分:尝试创建多个更小的接口并扩展它们。

参见:totalts.link/essentials-…

解决方案

不要使用 type 关键字,BaseEntityUserProduct 可以声明为接口。记住,接口不像 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;
}

额外加分的做法是进一步创建 WithIdWithCreatedAt 接口,它们分别表示带有 idcreatedAt 属性的对象。然后,你可以通过添加逗号,让 UserProduct 从这些接口扩展:

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 一开始是空对象,然后再添加 GrammyMercuryPrize 等键和值,并没有任何问题。不过,当你在 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;
} = {};

你也可以在 typeinterface 中使用同样的语法:

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;
}

虽然 typeinterface 两种示例都能实现已知键和动态键的组合,但在这个场景下,interface extends 比交叉类型更可取。在你的数据结构中同时支持默认键和动态键,可以提供很大的灵活性,让你的应用更容易适应不断变化的需求。

PropertyKey 类型

处理动态键时,一个很有用的类型是 PropertyKey。这个全局类型表示一个对象上所有可能使用的键的集合,包括 stringnumbersymbol。你可以在 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 添加一个类型注解,以支持动态的学科键。这里有四种方式可以做到:内联索引签名、typeinterface,或者 Record

参见:totalts.link/essentials-…

解决方案

这个练习有四种解决方案。

你可以使用内联索引签名:

const scores: {
  [key: string]: number;
} = {};

你可以使用接口:

interface Scores {
  [key: string]: number;
}

你可以使用类型:

type Scores = {
  [key: string]: number;
};

最后,你可以使用 Record

const scores: Record<string, number> = {};

练习 6-4:带动态键的默认属性

在这个练习中,你试图建模这样一种情况:你希望 Scores 对象上有一些必需键,也就是 mathenglishscience;但同时也希望可以添加动态属性,在这个例子中是 athleticsfrenchspanish

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 接口,为 mathenglishscience 指定默认键,同时允许添加任何其他学科。正确更新类型后,@ts-expect-error 下面的红色波浪线会消失,因为 science 会变成必需属性,但它确实缺失了。尝试使用 interface extends 来实现这一点。

参见:totalts.link/essentials-…

解决方案

下面是如何向 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 类型,而当前 Configurationsunknown。这个对象保存了 developmentproductionstaging 对应的键,每个键都关联着配置详情,例如 apiBaseUrltimeout。这里还有一个 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 会被正确禁止。

参见:totalts.link/essentials-…

解决方案

首先,我们考虑一次使用 Record 的尝试。你知道 configurations 对象的值会包含 apiBaseUrl,它是字符串;以及 timeout,它是数字。你可能会想使用 Record,把键设置为字符串,把值设置为一个带有 apiBaseUrltimeout 属性的对象:

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 时,fooSymbolbarSymbol 都有类型错误:

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 函数,让所有这些测试都通过。尽量保持简洁!

参见:totalts.link/essentials-…

解决方案

显而易见的答案是把 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 类型,其中 titleartistreleaseYeargenre 都是可选的。这意味着你可以创建一个函数,它只接收专辑属性的一部分:

const updateAlbum = (album: PartialAlbum) => {
  // ...
};

updateAlbum({title: "Geogaddi", artist: "Boards of Canada"});

在这个例子中,得益于使用了 Partial 类型助手,即使缺少 idreleaseYeargenre 属性,TypeScript 也不会抱怨。

Required 类型

Partial 相反的是 Required 类型,它会确保给定对象类型的所有属性都是必需的。

下面这个 Album 接口中,releaseYeargenre 属性被标记为可选:

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 接口的所有属性,所以没有错误。如果它缺少 releaseYeargenre,TypeScript 就会显示错误。

需要注意的是,RequiredPartial 都只作用于一层。例如,如果 Albumgenre 包含嵌套属性,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 类型助手,parentGenresubGenre 属性仍然是可选的。

注意

如果你发现自己需要一个深层的 Required 类型,可以看看 Sindre Sorhus 的 type-fest 库:github.com/sindresorhu…

Pick 类型

Pick 工具类型允许你从已有对象中挑选某些属性,创建一个新的对象类型。

例如,假设你想创建一个新类型,它只包含 Album 类型中的 titleartist 属性:

type AlbumData = Pick<Album, "title" | "artist">;

这会得到一个只包含这些属性的 AlbumData 类型。当你希望一个对象依赖另一个对象的形状时,这个工具非常有用。我们会在第 10 章进一步探索这一点。

Omit 类型

Omit 工具类型有点像 Pick 的反面。它允许你通过从已有类型中排除一部分属性来创建一个新类型。

例如,你可以使用 Omit 创建和前面用 Pick 创建出来一样的 AlbumData 类型,只不过这次是通过排除 idreleaseYeargenre 属性来实现:

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 时,他们面临一个选择:创建严格版本还是宽松版本。严格版本只允许省略有效键,也就是 idtitleartistreleaseYeargenre;而宽松版本没有这个约束。当时,在社区里更受欢迎的想法是实现一个宽松版本,所以团队最终选择了这种方式。鉴于 TypeScript 中的全局类型是全局可用的,不需要 import 语句,因此更宽松的版本被视为更安全的选择,因为它兼容性更好,也更不容易造成意外错误。

这个宽松版本的 Omit 对大多数情况来说已经足够了。只是要留心,因为它可能会以你意想不到的方式报错。我们会在第 15 章看一个严格版本的 Omit

注意

如果想进一步了解 Omit 背后的决策,可以参考 TypeScript 团队最初的讨论:github.com/microsoft/T…,以及添加 Omit 的 pull request:github.com/microsoft/T…,还有他们关于这个话题的最终说明:github.com/microsoft/T…

联合类型中的 Omit 和 Pick

OmitPick 类型与联合类型一起使用时,会表现出一些奇怪行为。例如,考虑这样一个场景:你有三个接口类型,分别是 AlbumCollectorEditionDigitalRelease

type Album = {
  id: string;
  title: string;
  genre: string;
};

type CollectorEdition = {
  id: string;
  title: string;
  limitedEditionFeatures: string[];
};

type DigitalRelease = {
  id: string;
  title: string;
  digitalFormat: string;
};

这些类型共享两个公共属性:idtitle,但每个类型也都有自己独有的属性。Album 类型包含 genreCollectorEdition 包含 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;
};

原因是,idtitle 是联合类型所有成员唯一共享的属性。所以,Omit 看起来会先把联合类型折叠成它的公共属性,也就是 idtitle,然后再移除 id

这一点特别烦人,因为 PartialRequired 在联合类型上可以按预期工作:

type PartialMusicProduct = Partial<MusicProduct>;

// 悬停在 PartialMusicProduct 上会显示:
type PartialMusicProduct =
  | Partial<Album>
  | Partial<CollectorEdition>
  | Partial<DigitalRelease>;

这个问题来自 Omit 处理联合类型的方式。它不会遍历每个联合成员,而是把它们合并成一个它能理解的单一结构。技术原因是,OmitPick 不是分布式的,也就是说,当你把它们用于联合类型时,它们不会分别作用于联合类型的每个成员。

为了解决这个问题,你可以创建一个 DistributiveOmit 类型。它的定义类似于 Omit,但会分别作用于每个联合成员。注意,类型定义中包含了 PropertyKey,以允许任何有效键类型:

type DistributiveOmit<T, K extends PropertyKey> = T extends any
  ? Omit<T, K>
  : never;

当你把 DistributiveOmit 应用于 MusicProduct 类型时,会得到预期结果:一个由省略 id 字段后的 AlbumCollectorEditionDigitalRelease 组成的联合类型:

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 类型存在一个问题。在调用 fetchUserexample 函数中,你期望只收到 nameemail 字段。这些字段只是 User 接口中已有内容的一部分。

你的任务是更新类型,使 fetchUser 只被期望返回 nameemail 字段。你可以使用前面看过的工具类型完成这个任务;不过为了额外练习,也可以尝试只使用接口。

参见:totalts.link/essentials-…

解决方案

这个问题有很多种解决方式。使用 Pick 工具类型,你可以创建一个只包含 User 接口中 nameemail 属性的新类型:

type PickedUser = Pick<User, "name" | "email">;

然后,可以更新 fetchUser 函数,让它返回一个 PickedUserPromise

const fetchUser = async (): Promise<PickedUser> => {
  // ...

你也可以使用 Omit 工具类型创建一个新类型,从 User 接口中排除 idrole 属性:

type OmittedUser = Omit<User, "id" | "role">;

然后,可以更新 fetchUser 函数,让它返回一个 OmittedUserPromise

const fetchUser = async (): Promise<OmittedUser> => {
  // ...

另一种可能是创建一个 NameAndEmail 接口,其中包含 name 属性和 email 属性,并更新 User 接口,移除这些属性,改为扩展它们:

interface NameAndEmail {
  name: string;
  email: string;
}

interface User extends NameAndEmail {
  id: string;
  role: string;
}

然后,fetchUser 函数可以返回一个 NameAndEmailPromise

const fetchUser = async (): Promise<NameAndEmail> => {
  // ...
};

使用 Omit 意味着随着源对象增长,目标对象也会一起增长;而使用 Pickinterface 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 中的所有其他属性都是可选的。

参见:totalts.link/essentials-…

解决方案

结合使用 OmitPartial,可以创建一个类型:它从 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 类型表示所有可能的键类型:stringnumbersymbol

TypeScript 的内置工具类型有助于减少代码重复。Partial<T> 会让所有属性变成可选,而 Required<T> 会让所有属性变成必需。Pick<T, K> 会选择特定属性,Omit<T, K> 则会排除它们。

OmitPick 与联合类型一起使用时,它们不会像预期那样分布到联合类型的每个成员上。使用条件类型创建分布式版本,可以解决这个限制,确保这些工具在联合类型上也能以可预测的方式工作。