对象类型

6 阅读29分钟

对象类型

学习 TypeScript 的高级类型系统:扩展对象、交叉类型、接口、动态键和工具类型。

6

到目前为止,我们只在“对象字面量”(使用 {} 和类型别名定义)的上下文中讨论过对象类型。

但是 TypeScript 提供了许多工具,让你可以更具表现力地使用对象类型。你可以对继承进行建模,从现有对象类型创建新的对象类型,以及使用动态键。

扩展对象

让我们通过研究如何在 TypeScript 中从其他对象类型构建对象类型来开始我们的探索。

交叉类型

交叉类型允许我们将多个对象类型合并为一个单一类型。它使用 & 操作符。你可以将其视为 | 操作符的反向操作。& 操作符表示类型之间的“与”关系,而不是“或”关系。

使用交叉操作符 & 可以将多个独立的类型组合成一个单一类型。

考虑以下 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,
};

如果在创建新对象时未满足 AlbumSales 类型的约定,TypeScript 将会报错。

也可以交叉两个以上的类型:

type AlbumSales = Album & SalesData & { genre: string };

这是从现有类型创建新类型的一种有用方法。

交叉类型与原始类型

值得注意的是,交叉类型也可以与原始类型(如 stringnumber)一起使用——尽管这通常会产生奇怪的结果。

例如,让我们尝试交叉 stringnumber

type StringAndNumber = string & number;

你认为 StringAndNumber 是什么类型?它实际上是 never。这是因为 stringnumber 具有无法组合在一起的固有属性。

当你交叉两个具有不兼容属性的对象类型时,也会发生这种情况:

type User1 = {
  age: number;
};

type User2 = {
  age: string;
};

type User = User1 & User2;
      // type User = User1 & User2
      // 此时 User 类型中 age 的类型为 never

在这种情况下,age 属性解析为 never,因为单个属性不可能同时是 numberstring

接口 (Interfaces)

到目前为止,我们一直只使用 type 关键字来定义对象类型。经验丰富的 TypeScript 程序员可能会抓狂地想:“为什么我们还不讨论接口?!”。

接口是 TypeScript 最著名的特性之一。它们随 TypeScript 的最初版本一起发布,并被认为是该语言的核心部分。

接口允许你使用与 type 略有不同的语法来声明对象类型。让我们比较一下语法:

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

interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}

它们基本上是相同的,除了关键字和等号。但认为它们可以互换是一个常见的错误。它们并非如此。

它们具有相当不同的能力,我们将在本节中探讨。

接口扩展 (interface extends)

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

这种结构使我们能够创建具有清晰继承关系的更具体的专辑表示:

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

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

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

交叉类型 vs 接口扩展

我们现在已经介绍了两种用于扩展对象类型的 TypeScript 语法:&interface extends。那么,哪一个更好呢?

你应该选择 interface extends,原因有二。

我们之前看到,当你交叉两个具有不兼容属性的对象类型时,TypeScript 会将该属性解析为 never

type User1 = {
  age: number;
};

type User2 = {
  age: string;
};

type User = User1 & User2; // User 中 age 类型为 never

当使用 interface extends 时,如果你尝试使用不兼容的属性扩展接口,TypeScript 会在定义时就引发错误:

interface User1 {
  age: number;
}

interface User extends User1 {
// 接口 'User' 错误地扩展了接口 'User1'。
//   属性 'age' 的类型不兼容。
//     类型 'string' 不可分配给类型 'number'。2430
  age: string;
}

这有很大的不同,因为它实际上会引发错误。对于交叉类型,TypeScript 仅在您尝试访问 age 属性时才会引发错误,而不是在定义它时。

因此,interface extends 在构建类型时更利于捕获错误。

更好的 TypeScript 性能

当你在 TypeScript 中工作时,类型的性能应该是你需要考虑的问题。在大型项目中,你定义类型的方式会对 IDE 的响应速度以及 tsc 检查代码所需的时间产生重大影响。

interface extends 在 TypeScript 性能方面远优于交叉类型。对于交叉类型,每次使用时都会重新计算交叉部分。这可能会很慢,尤其是在处理复杂类型时。

接口更快。TypeScript 可以根据其名称缓存接口的结果类型。因此,如果你使用 interface extends,TypeScript 只需要计算一次类型,然后每次使用该接口时都可以重用它。

结论

interface extends 在捕获错误和 TypeScript 性能方面都更好。这并不意味着你需要使用 interface 来定义所有对象类型——我们稍后会谈到这一点。但是,如果你需要让一个对象类型扩展另一个对象类型,你应该尽可能使用 interface extends

类型 (Types) vs 接口 (Interfaces)

既然我们知道了 interface extends 在扩展对象类型方面的优势,一个自然的问题就出现了。我们是否应该默认使用 interface 来定义所有类型?

让我们看看类型和接口之间的一些比较点。

类型可以是任何东西

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

type Union = string | number;

当我们声明一个类型别名时,我们只是给一个现有的类型起了一个名字(或别名)。

另一方面,interface 只能表示对象类型(以及函数,我们稍后会深入探讨)。

声明合并 (Declaration Merging)

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

这是一个 Album 接口的示例,其中包含 titleartist 属性:

interface Album {
  title: string;
  artist: string;
}

但是,假设在同一个文件中,你不小心声明了另一个 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[];
}

这与 type 非常不同,如果你尝试两次声明相同的类型,type 会报错:

type Album = {
// Duplicate identifier 'Album'.2300  (标识符“Album”重复。)
  title: string;
  artist: string;
};

type Album = {
// Duplicate identifier 'Album'.2300  (标识符“Album”重复。)
  releaseYear: number;
  genres: string[];
};

从 JavaScript 的角度来看,接口的这种行为感觉很奇怪。我曾因为在同一个 2000 多行的文件中有两个同名接口而浪费了数小时的时间。它存在是有充分理由的——我们将在后面的章节中探讨——但它有点像一个陷阱。

声明合并及其有些出乎意料的行为,让我对使用接口有点警惕。

结论

那么,你应该使用 type 还是 interface 来声明简单的对象类型呢?

我倾向于默认使用 type,除非我需要使用 interface extends。这是因为 type 更灵活,并且不会发生意外的声明合并。

但是,这是一个很微妙的选择。如果你选择相反的方式,我也不会怪你。许多来自更面向对象背景的人会更喜欢 interface,因为它对他们来说更熟悉,就像其他语言一样。

练习

练习 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 类型。

练习 1: 创建交叉类型

练习 2: 扩展接口

完成上一个练习后,你将拥有一个 BaseEntity 类型以及与之交叉的 UserProduct 类型。

这次,你的任务是将这些类型重构为接口,并使用 extends 关键字来扩展 BaseEntity 类型。作为加分项,尝试创建并扩展多个更小的接口。

练习 2: 扩展接口

解决方案 1: 创建交叉类型

为了解决这个挑战,我们将创建一个新的 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 的行为与之前完全相同,但代码重复更少。

解决方案 2: 扩展接口

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

对于加分项,我们可以通过创建表示具有 idcreatedAt 属性的对象的 WithIdWithCreatedAt 接口来进一步实现。然后,我们可以让 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 编译器会感谢我们的。

动态对象键

使用对象时,我们通常不会总是知道将要使用的确切键。

在 JavaScript 中,我们可以从一个空对象开始,然后动态地向其添加键和值:

// JavaScript 示例
const albumAwards = {};
albumAwards.Grammy = true;
albumAwards.MercuryPrize = false;
albumAwards.Billboard = true;

然而,当我们在 TypeScript 中尝试向对象动态添加键时,我们会收到错误:

// TypeScript 示例
const albumAwards = {};
albumAwards.Grammy = true; // 属性“Grammy”在类型“{}”上不存在。2339
albumAwards.MercuryPrize = false; // 属性“MercuryPrize”在类型“{}”上不存在。2339
albumAwards.Billboard = true; // 属性“Billboard”在类型“{}”上不存在。2339

这可能让人感觉不方便。你可能会认为,基于 TypeScript 能够收窄我们代码的能力,它应该能够判断出我们正在向对象添加键。

在这种情况下,TypeScript 更倾向于保守。它不会让你向它不知道的对象添加键。这是因为 TypeScript 试图阻止你犯错误。

我们需要告诉 TypeScript 我们希望能够动态添加键。让我们看看一些方法。

使用索引签名处理动态键

让我们再看一下上面的代码。

const albumAwards = {};
albumAwards.Grammy = true; // 属性“Grammy”在类型“{}”上不存在。2339

我们在这里所做的技术术语是“索引”。我们正在用字符串键 Grammy 索引到 albumAwards,并给它赋一个值。

为了支持这种行为,我们想告诉 TypeScript,每当我们尝试用字符串索引到 albumAwards 时,我们应该期望得到一个布尔值。

为此,我们可以使用“索引签名”。

以下是我们如何为 albumAwards 对象指定索引签名的方法。

const albumAwards: {
  [index: string]: boolean;
} = {};

albumAwards.Grammy = true;
albumAwards.MercuryPrize = false;
albumAwards.Billboard = true;

[index: string]: boolean 语法是一个索引签名。它告诉 TypeScript albumAwards 可以有任何字符串键,并且值将始终是布尔值。

我们可以为 index 选择任何名称。它只是一个描述。

const albumAwards: {
  [iCanBeAnything: string]: boolean;
} = {};

同样的语法也可以用于类型和接口:

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 albumAwards1: Record<"Grammy" | "MercuryPrize" | "Billboard", boolean> = {
  Grammy: true,
  MercuryPrize: false,
  Billboard: true,
};

const albumAwards2: {
  [index: "Grammy" | "MercuryPrize" | "Billboard"]: boolean; // 索引签名参数类型不能是字面量类型或泛型类型。请考虑改用映射对象类型。1337
} = {
  Grammy: true,
  MercuryPrize: false,
  Billboard: true,
};

索引签名不能使用字面量类型,但 Record 可以。我们将在稍后的章节中探讨映射类型时了解原因。

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

这种技术在使用接口和 extends 关键字时也适用:

interface BaseAwards {
  Grammy: boolean;
  MercuryPrize: boolean;
  Billboard: boolean;
}

interface ExtendedAlbumAwards extends BaseAwards {
  [award: string]: boolean;
}

这个版本更可取,因为通常情况下,interface extends 优于交叉类型。

能够在我们的数据结构中同时支持默认键和动态键,可以为适应应用程序中不断变化的需求提供很大的灵活性。

PropertyKey

在处理动态键时,一个有用的类型是 PropertyKey

PropertyKey 类型是一个全局类型,表示可以在对象上使用的所有可能键的集合,包括字符串、数字和符号。你可以在 TypeScript 的 ES5 类型定义文件中找到它的类型定义:

// 在 lib.es5.d.ts 内部
declare type PropertyKey = string | number | symbol;

因为 PropertyKey 适用于所有可能的键,所以它非常适合处理你不确定键类型为何的动态键。

例如,当使用索引签名时,你可以将键类型设置为 PropertyKey 以允许任何有效的键类型:

type Album = {
  [key: PropertyKey]: string;
};

object

stringnumberboolean 类似,object 是 TypeScript 中的一个全局类型。

它表示的类型可能比你预期的要多。object 并不仅仅表示像 {}new Object() 这样的对象,它表示任何非原始类型。这包括数组、函数和对象。

所以像这样的函数:

function acceptAllNonPrimitives(obj: object) {}

会接受任何非原始值:

acceptAllNonPrimitives({});
acceptAllNonPrimitives([]);
acceptAllNonPrimitives(() => {});

但对原始类型会报错:

acceptAllNonPrimitives(1); // 类型 'number' 的参数不能赋给类型 'object' 的参数。2345
acceptAllNonPrimitives("hello"); // 类型 'string' 的参数不能赋给类型 'object' 的参数。2345
acceptAllNonPrimitives(true); // 类型 'boolean' 的参数不能赋给类型 'object' 的参数。2345

这意味着 object 类型本身很少有用。使用 Record 通常是更好的选择。例如,如果你想接受任何对象类型,可以使用 Record<string, unknown>

练习

练习 1: 使用索引签名处理动态键

这里我们有一个名为 scores 的对象,我们试图给它赋几个不同的属性:

const scores = {};
scores.math = 95;    // 属性“math”在类型“{}”上不存在。2339
scores.english = 90; // 属性“english”在类型“{}”上不存在。2339
scores.science = 85; // 属性“science”在类型“{}”上不存在。2339

你的任务是给 scores 一个类型注解以支持动态的学科键。有三种方法:内联索引签名、类型别名、接口或 Record

练习 1: 使用索引签名处理动态键

练习 2: 具有动态键的默认属性

在这里,我们试图模拟一种情况,即我们希望在 scores 对象上有一些必需的键——mathenglishscience

但我们也想添加动态属性。在这种情况下,是 athleticsfrenchspanish

interface Scores {}

// @ts-expect-error science 应该被提供
// 未使用的 '@ts-expect-error' 指令。2578
const scores: Scores = {
  math: 95,
  english: 90,
};

scores.athletics = 100; // 属性“athletics”在类型“Scores”上不存在。2339
scores.french = 75;    // 属性“french”在类型“Scores”上不存在。2339
scores.spanish = 70;   // 属性“spanish”在类型“Scores”上不存在。2339

scores 的定义应该报错,因为 science 缺失了——但它没有报错,因为我们对 Scores 的定义目前是一个空对象。

你的任务是更新 Scores 接口,为 mathenglishscience 指定默认键,同时允许添加任何其他学科。正确更新类型后,@ts-expect-error 下方的红色波浪线将会消失,因为 science 将是必需的但缺失了。看看你是否可以使用 interface extends 来实现这一点。

练习 2: 具有动态键的默认属性

练习 3: 使用 Record 限制对象键

这里我们有一个 configurations 对象,其类型为 Configurations,目前是 unknown

该对象包含 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' 指令。2578
  notAllowed: {
    apiBaseUrl: "https://staging.example.com",
    timeout: 8000,
  },
};

更新 Configurations 类型,以便只允许 Environment 中的键出现在 configurations 对象上。正确更新类型后,@ts-expect-error 下方的红色波浪线将会消失,因为 notAllowed 将被正确禁止。

练习 3: 使用 Record 限制对象键

练习 4: 动态键支持

考虑这个 hasKey 函数,它接受一个对象和一个键,然后在该对象上调用 object.hasOwnProperty

const hasKey = (obj: object, key: string) => {
  return obj.hasOwnProperty(key);
};

这个函数有几个测试用例:

第一个测试用例检查它是否适用于字符串键,这没有任何问题。正如预期的那样,hasKey(obj, "foo") 将返回 truehasKey(obj, "bar") 将返回 false

it("应该适用于字符串键", () => {
  const obj = {
    foo: "bar",
  };
  expect(hasKey(obj, "foo")).toBe(true);
  expect(hasKey(obj, "bar")).toBe(false);
});

一个检查数字键的测试用例确实存在问题,因为该函数期望一个字符串键:

it("应该适用于数字键", () => {
  const obj = {
    1: "bar",
  };
  expect(hasKey(obj, 1)).toBe(true); // 类型 'number' 的参数不能赋给类型 'string' 的参数。2345
  expect(hasKey(obj, 2)).toBe(false); // 类型 'number' 的参数不能赋给类型 'string' 的参数。2345
});

因为对象也可以有一个符号作为键,所以也有针对该情况的测试。它目前在调用 hasKey 时对 fooSymbolbarSymbol 存在类型错误:

it("应该适用于符号键", () => {
  const fooSymbol = Symbol("foo");
  const barSymbol = Symbol("bar");
  const obj = {
    [fooSymbol]: "bar",
  };
  expect(hasKey(obj, fooSymbol)).toBe(true); // 类型 'typeof fooSymbol' 的参数不能赋给类型 'string' 的参数。2345
  expect(hasKey(obj, barSymbol)).toBe(false); // 类型 'typeof barSymbol' 的参数不能赋给类型 'string' 的参数。2345
});

你的任务是更新 hasKey 函数,以便所有这些测试都能通过。尝试尽可能简洁!

练习 4: 动态键支持

解决方案 1: 使用索引签名处理动态键

以下是三种解决方案:

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

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

或者一个接口:

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

或者一个类型别名:

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

或者最后,一个 Record

const scores: Record<string, number> = {};
解决方案 2: 具有动态键的默认属性

以下是如何向 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 接口,你可以访问它。

解决方案 3: 限制对象键
第一次尝试使用 Record 失败

我们知道 Configurations 对象的值将是 apiBaseUrl(一个字符串)和 timeout(一个数字)。

可能会倾向于使用 Record 将键设置为字符串,并将值设置为具有 apiBaseUrltimeout 属性的对象:

type Configurations = Record<
  string,
  {
    apiBaseUrl: string;
    timeout: number;
  }
>;

然而,将键设为 string 仍然允许将 notAllowed 键添加到对象中。我们需要使键依赖于 Environment 类型。

正确的方法

相反,我们可以在 Record 内部将 key 指定为 Environment

type Configurations = Record<
  Environment,
  {
    apiBaseUrl: string;
    timeout: number;
  }
>;

现在,当对象包含一个在 Environment 中不存在的键(如 notAllowed)时,TypeScript 将会抛出错误。

解决方案 4: 动态键支持

显而易见的答案是将 key 的类型更改为 string | number | symbol

const hasKey = (obj: object, key: string | number | symbol) => {
  return obj.hasOwnProperty(key);
};

然而,有一个更简洁得多的解决方案。

将鼠标悬停在 hasOwnProperty 上会显示其类型定义:

// (method) Object.hasOwnProperty(v: PropertyKey): boolean

回想一下,PropertyKey 类型表示键可以拥有的所有可能值。这意味着我们可以将其用作键参数的类型:

const hasKey = (obj: object, key: PropertyKey) => {
  return obj.hasOwnProperty(key);
};

完美。

使用工具类型减少重复

在 TypeScript 中使用对象类型时,你经常会发现自己的对象类型共享共同的属性。这可能导致大量重复的代码。

我们已经看到了如何使用 interface extends 来帮助我们对继承进行建模,但 TypeScript 还为我们提供了直接操作对象类型的工具。借助其内置的工具类型,我们可以从类型中移除属性、使它们可选等等。

Partial

Partial 工具类型允许你从现有对象类型创建一个新的对象类型,只是其所有属性都变为可选的。

考虑一个 Album 接口,其中包含有关专辑的详细信息:

interface Album {
  id: number;
  title: string;
  artist: string;
  releaseYear: number;
  genre: string;
}

当我们想要更新专辑信息时,我们可能不会一次性拥有所有信息。例如,在专辑发行前很难决定给专辑分配什么流派。

使用 Partial 工具类型并传入 Album,我们可以创建一个允许我们更新专辑任何属性子集的类型:

type PartialAlbum = Partial<Album>;

现在我们有了一个 PartialAlbum 类型,其中 idtitleartistreleaseYeargenre 都是可选的。

这意味着我们可以创建一个只接收专辑属性子集的函数:

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

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

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",
};
Required 与嵌套属性

需要注意的一个重要事项是,RequiredPartial 都只作用于一层深度。例如,如果 Albumgenre 包含嵌套属性,Required<Album> 不会使子属性变为必需:

type Album = {
  title: string;
  artist: string;
  releaseYear?: number;
  genre?: {
    parentGenre?: string;
    subGenre?: string;
  };
};

type RequiredAlbum = Required<Album>;
// type RequiredAlbum = {
//   title: string;
//   artist: string;
//   releaseYear: number; // 变为必需
//   genre: {             // genre 本身变为必需,但其内部属性不变
//       parentGenre?: string;
//       subGenre?: string;
//   };
// }

如果你发现自己需要一个深度 Required 类型,请查看 Sindre Sorhus 的 type-fest 库。

Pick

Pick 工具类型允许你通过从现有对象中选取某些属性来创建新的对象类型。

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

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

这将导致 AlbumData 成为一个只包含 titleartist 属性的类型。

当一个对象依赖于另一个对象的形状时,这非常有用。我们将在关于从其他类型派生类型的章节中更深入地探讨这一点。

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 比 Pick 更宽松

使用 Omit 时,你可以排除对象类型上不存在的属性。

例如,使用我们的 Album 类型创建 AlbumWithoutProducer 类型不会导致错误,即使 producerAlbum 上不存在:

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

type AlbumWithoutProducer = Omit<Album, "producer">; // 不会报错

如果我们尝试使用 Pick 创建 AlbumWithOnlyProducer 类型,我们会得到一个错误,因为 producerAlbum 上不存在:

type AlbumWithOnlyProducer = Pick<Album, "producer">;
// 类型“"producer"”不满足约束“keyof Album”。2344

为什么这两个工具类型的行为不同?

当 TypeScript 团队最初实现 Omit 时,他们面临着创建一个严格版还是宽松版 Omit 的决定。严格版只允许省略有效的键(idtitleartistreleaseYeargenre),而宽松版则没有这个限制。

当时,社区中更流行的想法是实现一个宽松版本,所以他们选择了那个。鉴于 TypeScript 中的全局类型是全局可用的,并且不需要 import 语句,宽松版本被视为更安全的选择,因为它更兼容,并且不太可能导致意外错误。

虽然可以创建严格版本的 Omit,但宽松版本对于大多数情况应该足够了。只是要注意,因为它可能会以你意想不到的方式出错。

我们将在本书后面实现一个严格版本的 Omit

有关 Omit 背后决策的更多见解,请参阅 TypeScript 团队最初关于添加 Omit讨论拉取请求,以及他们关于该主题的最终说明

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——所有类型共享的其他属性(不包括 id)中剩下的那个。

// 期望:
type MusicProductWithoutId =
  | Omit<Album, "id">
  | Omit<CollectorEdition, "id">
  | Omit<DigitalRelease, "id">;

// 实际:
type MusicProductWithoutId = {
  title: string;
};

考虑到 PartialRequired 与联合类型按预期工作,这一点尤其令人烦恼:

type PartialMusicProduct = Partial<MusicProduct>;

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

这源于 Omit 处理联合类型的方式。它不是遍历每个联合成员,而是将它们合并成一个它可以理解的单一结构。

其技术原因是 OmitPick 不是分配性的(distributive)。这意味着当你将它们与联合类型一起使用时,它们不会单独作用于每个联合成员。

DistributiveOmit 和 DistributivePick 类型

为了解决这个问题,我们可以创建一个 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 PropertyKey> = T extends any
  ? Pick<T, K>
  : never;

练习

练习 1: 期望特定属性

在这个练习中,我们有一个 fetchUser 函数,它使用 fetch 访问名为 APIUser 的端点,并返回一个 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;
};

// 假设 Equal 是一个辅助类型,用于比较类型是否相等
// type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
// type Expect<T extends true> = T;

const example = async () => {
  const user = await fetchUser();
  // @ts-expect-error 我们只期望 name 和 email
  type test = Expect<Equal<typeof user, { name: string; email: string }>>; // 类型 'false' 不满足约束 'true'。2344
};

由于我们处于异步函数中,我们确实想使用 Promise,但是这个 User 类型存在问题。

在调用 fetchUserexample 函数中,我们只期望接收 nameemail 字段。这些字段只是 User 接口中存在的一部分。

你的任务是更新类型定义,以便只期望从 fetchUser 返回 nameemail 字段。

你可以使用我们已经看过的辅助类型来完成此任务,但为了额外练习,请尝试仅使用接口。

练习 1: 期望特定属性

练习 2: 更新产品

这里我们有一个 updateProduct 函数,它接受两个参数:一个 id 和一个 productInfo 对象,该对象派生自 Product 类型,但不包括 id 字段。

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

const updateProduct = (id: number, productInfo: Product) => {
  // 对 productInfo 执行某些操作
};

这里的转折在于,在产品更新期间,我们可能不想同时修改其所有属性。因此,并非所有属性都必须传递给函数。

这意味着我们有几个不同的测试场景。例如,只更新名称、只更新价格或只更新描述。更新名称和价格或名称和描述等组合也进行了测试。

updateProduct(1, {
  // 类型 '{ name: string; }' 的参数不能赋给类型 'Product' 的参数。
  //   类型 '{ name: string; }' 缺少类型 'Product' 中的以下属性: id, price, description2345
  name: "Book",
});

updateProduct(1, {
  // 类型 '{ price: number; }' 的参数不能赋给类型 'Product' 的参数。
  //   类型 '{ price: number; }' 缺少类型 'Product' 中的以下属性: id, name, description2345
  price: 12.99,
});

你的挑战是修改 productInfo 参数以反映这些要求。id 应该仍然不存在于 productInfo 中,但我们也希望 productInfo 中的所有其他属性都是可选的。

练习 2: 更新产品

解决方案 1: 期望特定属性

解决这个问题的方法有很多。以下是一些示例:

使用 Pick

使用 Pick 工具类型,我们可以创建一个新类型,该类型仅包含 User 接口中的 nameemail 属性:

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

然后 fetchUser 函数可以更新为返回 PickedUserPromise

const fetchUser = async (): Promise<PickedUser> => {
  // ...
  const response = await fetch("/api/user");
  const user = await response.json();
  return user; // 假设 API 返回的数据结构与 PickedUser 兼容
};
使用 Omit

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

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

然后 fetchUser 函数可以更新为返回 OmittedUserPromise

const fetchUser = async (): Promise<OmittedUser> => {
  // ...
  const response = await fetch("/api/user");
  const user = await response.json();
  return user; // 假设 API 返回的数据结构与 OmittedUser 兼容
};
扩展接口

我们可以创建一个包含 nameemail 属性的 NameAndEmail 接口,同时更新 User 接口以移除这些属性,转而扩展它们:

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

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

然后 fetchUser 函数可以返回 NameAndEmailPromise

const fetchUser = async (): Promise<NameAndEmail> => {
  // ...
  const response = await fetch("/api/user");
  const user = await response.json();
  return user; // 假设 API 返回的数据结构与 NameAndEmail 兼容
};

Omit 意味着对象会随着源对象的增长而增长。Pickinterface extends 意味着对象将保持相同的大小。因此,根据需求,你可以选择最佳方法。

解决方案 2: 更新产品

使用 OmitPartial组合将允许我们创建一个类型,该类型从 Product 中排除 id 字段,并使所有其他属性可选。

在这种情况下,将 Omit<Product, "id"> 包装在 Partial 中将移除 id,同时使所有剩余属性可选:

const updateProduct = (
  id: number,
  productInfo: Partial<Omit<Product, "id">>,
) => {
  // 对 productInfo 执行某些操作
};