掌握TypeScript中的映射类型

1,049 阅读2分钟

映射类型是一个方便的TypeScript功能,允许作者保持他们的类型DRY("不要重复自己")。然而,由于它们介于编程和元编程之间,一开始可能很难理解。

在这篇文章中,我们将介绍一些实现映射类型的基础概念,然后通过一个高级的、真实世界的例子。

为什么在 TypeScript 中使用映射类型?

当需要一个类型从另一个类型派生(并与之保持同步)时,在程序中使用映射类型特别有用。

// Configuration values for the current user
type AppConfig = {
  username: string;
  layout: string;
};

// Whether or not the user has permission to change configuration values
type AppPermissions = {
  changeUsername: boolean;
  changeLayout: boolean;
};

这个例子是有问题的,因为AppConfigAppPermissions 之间存在隐性关系。每当一个新的配置值被添加到AppConfig ,在AppPermissions 中也必须有一个相应的boolean 值。

让类型系统管理这种关系比依靠未来程序编辑器的纪律来同时对两个类型进行适当的更新要好。

我们将在后面深入研究映射类型语法的具体内容,但这里是一个使用映射类型而不是显式类型的相同例子的预览。

// Configuration values for the current user
type AppConfig = {
  username: string;
  layout: string;
};

// Whether or not the user has permission to change configuration values
type AppPermissions = {
  [Property in keyof AppConfig as `change${Capitalize<Property>}`]: boolean
};

映射类型的基本概念

映射类型建立在上述每个概念和TypeScript特性的基础上。

什么是映射类型?

在计算机科学背景下,术语 "映射 "意味着将一个东西转化为另一个,或者,更常见的是指将类似的项目转化为不同的转化项目列表。这个想法最熟悉的应用可能是Array.prototype.map() ,它被用于日常的TypeScript和JavaScript编程中。

[1, 2, 3].map(value => value.toString()); // Yields ["1", "2", "3"]

在这里,我们把数组中的每个数字都映射成了它的字符串表示。因此,TypeScript中的映射类型意味着我们通过对其每个属性应用转换,将一个类型转换为另一个类型。

TypeScript 中的索引访问类型

TypeScript 作者可以通过按名称查找来访问一个属性的类型。

type AppConfig = {
  username: string;
  layout: string;
};

type Username = AppConfig["username"];

在这种情况下,Username 的解析类型是string 。有关索引访问类型的更多信息,请参阅官方文档

索引签名

当类型的属性的实际名称不知道,但它们将引用的数据类型是已知的时候,索引签名是很方便的。

type User = {
  name: string;
  preferences: {
    [key: string]: string;
  }
};

const currentUser: User = {
  name: 'Foo Bar',
  preferences: {
    lang: 'en',
  },
};
const currentLang = currentUser.preferences.lang;

在这个例子中,TypeScript编译器报告说,currentLang 的类型是string 而不是any 。这个功能,与下面详述的keyof 操作符一起,是使映射类型成为可能的核心组件之一。关于索引签名的更多信息,请参阅对象类型的官方文档。

在TypeScript中使用联合类型

联盟类型是两个或多个类型的组合。它向 TypeScript 编译器发出信号,底层值的类型可以是联合中的任何一种类型。这是一个有效的TypeScript程序。

type StringOrNumberUnion = string | number;

let value: StringOrNumberUnion = 'hello, world!';
value = 100;

这里有一个更复杂的例子,显示了编译器对联合类型所能提供的一些高级保护。

type Animal = {
  name: string;
  species: string;
};

type Person = {
  name: string;
  age: number;
};

type AnimalOrPerson = Animal | Person;

const value: AnimalOrPerson = loadFromSomewhereElse();

console.log(value.name); // No problem, both Animal and Person have the name property.
console.log(value.age); // Compilation error; value might not have the age property if it is an Animal.

if ('age' in value) {
  console.log(value.age); // No problem, TS knows that value has the age property, and therefore it must be a Person if we're inside this if block.
}

关于联合类型的更多信息,请参见日常类型的文档。

使用keyof 类型操作符

keyof 类型操作符返回传递给它的类型的键的联合。比如说。

type AppConfig = {
  username: string;
  layout: string;
};

type AppConfigKey = keyof AppConfig;

AppConfigKey 类型被解析为"username" | "layout" 。请注意,这也与索引签名一起工作。

type User = {
  name: string;
  preferences: {
    [key: string]: string;
  }
};

type UserPreferenceKey = keyof User["preferences"];

UserPreferenceKey 类型解析为string | numbernumber ,因为通过数字访问JavaScript对象属性是有效的语法)。在这里阅读关于keyof 类型操作符的信息。

映射类型:一个真实世界的例子

现在我们已经介绍了 TypeScript 映射类型功能的基础,让我们来看看一个详细的真实世界的例子。假设我们的程序跟踪电子设备及其制造商和价格。我们可能有一个这样的类型来代表每个设备。

type Device = {
  manufacturer: string;
  price: number;
};

现在,我们想确保我们有办法将这些设备以人类可读的格式显示给用户,所以我们将为一个对象添加一个新的类型,这个对象可以用适当的格式化函数将Device 的每个属性进行格式化。

type DeviceFormatter = {
  [Key in keyof Device as `format${Capitalize<Key>}`]: (value: Device[Key]) => string;
};

让我们把这个代码块拆开,一块一块地看。

Key in keyof Device 使用keyof 类型操作符来生成Device 中所有键的联合。把它放在一个索引签名里面,本质上是遍历Device 的所有属性,并把它们映射到DeviceFormatter 的属性。

format${Capitalize<Key>} 是映射的转换部分,使用键重映射模板字面类型将属性名称从x 改为formatX

(value: Device[Key]) => string; 是我们利用索引访问类型Device[Key] 来表示格式化函数的value 参数是我们正在格式化的属性的类型。所以,formatManufacturer 取一个string (制造商),而formatPrice 取一个number (价格)。

下面是DeviceFormatter 类型的样子。

type DeviceFormatter = {
  formatManufacturer: (value: string) => string;
  formatPrice: (value: number) => string;
};

现在,让我们假设我们在我们的Device 类型中添加第三个属性,releaseYear

type Device = {
  manufacturer: string;
  price: number;
  releaseYear: number;
}

感谢映射类型的力量,DeviceFormatter 类型被自动扩展成这样,我们不需要做任何额外的工作。

type DeviceFormatter = {
  formatManufacturer: (value: string) => string;
  formatPrice: (value: number) => string;
  formatReleaseYear: (value: number) => string;
};

DeviceFormatter 的任何实现都必须添加新的函数,否则编译会失败。看吧

奖励:一个可重用的具有泛型的格式化类型

假设现在我们的程序不仅需要跟踪电子设备,还需要跟踪这些设备的配件。

type Accessory = {
  color: string;
  size: number;
};

同样,我们想要一个对象的类型,可以为Accessory 的所有属性提供字符串格式化功能。我们可以实现一个AccessoryFormatter 类型,类似于我们实现DeviceFormatter 的方式,但我们最终会得到大部分重复的代码。

type AccessoryFormatter = {
  [Key in keyof Accessory as `format${Capitalize<Key>}`]: (value: Accessory[Key]) => string;
};

唯一的区别是,我们用Accessory 替换了对Device 类型的引用。相反,我们可以创建一个通用类型,将DeviceAccessory 作为一个类型参数,并产生所需的映射类型。传统上,T 被用来表示类型参数。

type Formatter<T> = {
  [Key in keyof T as `format${Capitalize<Key & string>}`]: (value: T[Key]) => string;
}

注意,我们必须对我们的属性名称转换做一个轻微的改变。因为T 可能是任何类型,我们不能确定Key 是一个string (例如,数组有number 属性),所以我们采取属性名和string交集来满足Capitalize 的约束。

关于泛型的更多细节,请参见TypeScript文档中的泛型如何工作。现在我们可以替换我们定制的DeviceFormatterAccessoryFormatter 的实现,以使用泛型来代替。

type DeviceFormatter = Formatter<Device>;
type AccessoryFormatter = Formatter<Accessory>;

这里是完整的最终代码。

type Device = {
  manufacturer: string;
  price: number;
  releaseYear: number;
};

type Accessory = {
  color: string;
  size: number;
};

type Formatter<T> = {
  [Key in keyof T as `format${Capitalize<Key & string>}`]: (value: T[Key]) => string;
};

type DeviceFormatter = Formatter<Device>;
type AccessoryFormatter = Formatter<Accessory>;

const deviceFormatter: DeviceFormatter = {
  formatManufacturer: (manufacturer) => manufacturer,
  formatPrice: (price) => `$${price.toFixed(2)}`,
  formatReleaseYear: (year) => year.toString(),
};

const accessoryFormatter: AccessoryFormatter = {
  formatColor: (color) => color,
  formatSize: (size) => `${size} inches`,
};

在typescriptlang.org的TypeScript操场上试试这段代码。

总结

映射类型提供了一种强大的方式来自动保持相关类型的同步。它们还可以通过保持类型的干燥和避免重复输入(或复制和粘贴)类似的属性名称来帮助防止错误。