Skip to content

TypeScript 派生类型

Refer

Source: Deriving Types - June 28, 2024

Author: Matt Pocock

Translator: SenaoXi

Copyright Statement

Translation and Republication Notice:

This translation is provided for educational and informational purposes only. All intellectual property rights, including copyright, remain with the original author and/or publisher. This translation maintains the integrity of the original content while making it accessible to chinese readers.

Modifications Disclosure:

  • This is a complete and faithful translation of the original content with no substantive modifications.
  • This translation includes minor adaptations to improve clarity for chinese readers while preserving all essential information and viewpoints.
  • Sections marked with [†] contain supplementary explanations added by the translator to provide cultural or technical context.

Rights Reservation:

If you are the copyright holder and believe this translation exceeds fair use guidelines, please contact us at email. We are committed to respecting intellectual property rights and will promptly address any legitimate concerns.

技术背景

探索 TypeScript 的高级类型派生技术:keyoftypeof、索引访问类型,以及用 as const 实现枚举。

编写可维护代码的一个常见建议是"保持代码 DRY",或更明确地说,"不要重复自己"(Don't Repeat Yourself)。

在 JavaScript 中实现这一点的方法之一是将重复的代码提取到函数或变量中。这些函数和变量可以被重用、组合和以不同方式结合来创建新的功能。

在 TypeScript 中,我们可以将相同的原则应用于类型。

在本节中,我们将研究如何从其他类型派生类型。这让我们能够减少代码中的重复,并为类型创建单一的事实来源。

这使得我们可以在一个类型中进行更改,并让这些更改传播到整个应用程序中,而无需手动更新每个实例。

我们甚至会研究如何从值派生类型,以便我们的类型始终代表应用程序的运行时行为。

派生类型

派生类型是依赖于或继承自另一种类型结构的类型。我们可以使用到目前为止学习的一些工具来创建派生类型。

我们可以使用 interface extends 使一个接口继承另一个接口:

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

interface AlbumDetails extends Album {
  genre: string;
}

AlbumDetails 继承了 Album 的所有属性。这意味着对 Album 的任何更改都会传递到 AlbumDetailsAlbumDetails 是从 Album 派生的。

另一个例子是联合类型。

typescript
interface Triangle {
  type: 'triangle';
  sideLength: number;
}

interface Rectangle {
  type: 'rectangle';
  width: number;
  height: number;
}

type Shape = Triangle | Rectangle;

派生类型表示一种关系。这种关系是单向的。Shape 不能返回并修改 TriangleRectangle。但是对 TriangleRectangle 的任何更改都会影响到 Shape

设计良好的派生类型可以带来巨大的生产力提升。我们可以在一个地方做出更改,并让它们传播到整个应用程序中。这是保持代码 DRY 并充分利用 TypeScript 类型系统的强大方式。

这有一些权衡。我们可以将派生视为一种耦合。如果我们更改了其他类型依赖的类型,我们需要意识到这种更改的影响。我们将在本章末尾更详细地讨论派生与解耦。

现在,让我们看看 TypeScript 提供的一些用于派生类型的工具。

keyof 操作符

keyof 操作符允许你从对象类型中提取键并创建为联合类型。

从我们熟悉的 Album 类型开始:

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

我们可以使用 keyof Album 得到 "title""artist""releaseYear" 键的联合:

typescript
type AlbumKeys = keyof Album; // "title" | "artist" | "releaseYear"

由于 keyof 跟踪源的键,对类型做出的任何更改都会自动反映在 AlbumKeys 类型中。

typescript
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
  genre: string; // 添加了 'genre'
}

type AlbumKeys = keyof Album; // "title" | "artist" | "releaseYear" | "genre"

然后可以使用 AlbumKeys 类型来确保用于访问 Album 中值的键是有效的,如下面这个函数所示:

typescript
function getAlbumDetails(album: Album, key: AlbumKeys) {
  return album[key];
}

如果传递给 getAlbumDetails 的键不是 Album 的有效键,TypeScript 将显示错误:

typescript
getAlbumDetails(album, 'producer');
// 类型 '"producer"' 不能赋值给类型 'keyof Album'。

keyof 是从现有类型创建新类型的重要构建块。我们稍后将看到如何将它与 as const 一起使用来构建自己的类型安全枚举。

typeof 操作符

typeof 操作符允许你从值中提取类型。

假设我们有一个 albumSales 对象,其中包含一些专辑标题键和销售统计数据:

typescript
const albumSales = {
  'Kind of Blue': 5000000,
  'A Love Supreme': 1000000,
  'Mingus Ah Um': 3000000
};

我们可以使用 typeof 提取 albumSales 的类型,这将把它转变为一个类型,其中包含原始键作为字符串和它们的推断类型作为值:

typescript
type AlbumSalesType = typeof albumSales;

interface AlbumSalesType {
  'Kind of Blue': number;
  'A Love Supreme': number;
  'Mingus Ah Um': number;
}

现在我们有了 AlbumSalesType 类型,我们可以从中创建另一个派生类型。例如,我们可以使用 keyof 提取 albumSales 对象的键:

typescript
type AlbumTitles = keyof AlbumSalesType; // "Kind of Blue" | "A Love Supreme" | "Mingus Ah Um"

一种常见的模式是结合使用 keyoftypeof 从现有对象类型的键和值创建新类型:

typescript
type AlbumTitles = keyof typeof albumSales;

我们可以在函数中使用它来确保 title 参数是 albumSales 的有效键,例如查找特定专辑的销售情况:

typescript
function getSales(title: AlbumTitles) {
  return albumSales[title];
}

值得注意的是,typeof 不同于运行时使用的 typeof 操作符。TypeScript 可以根据它是在类型上下文还是值上下文中使用来区分它们:

typescript
// 运行时的 typeof
const albumSalesType = typeof albumSales; // "object"

// 类型 typeof
type AlbumSalesType = typeof albumSales;

interface AlbumSalesType {
  'Kind of Blue': number;
  'A Love Supreme': number;
  'Mingus Ah Um': number;
}

只要你需要基于运行时值(包括对象、函数、类等)提取类型,就使用 typeof 关键字。它是从值派生类型的强大工具,也是我们稍后将探讨的其他模式的关键构建块。

你不能从类型创建运行时值

我们已经看到 typeof 可以从运行时值创建类型,但重要的是要注意,没有办法从类型创建值。

换句话说,没有 valueof 操作符:

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

const album = valueof Album; // 不起作用!

TypeScript 的类型在运行时会消失,所以没有内置的方法可以从类型创建值。换句话说,你可以从"值世界"移动到"类型世界",但不能反向操作。

索引访问类型

TypeScript 中的索引访问类型允许你访问另一个类型的属性。这类似于你在运行时访问对象中属性的值的方式,但它在类型级别上操作。

例如,我们可以使用索引访问类型从 Album 中提取 title 属性的类型:

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

如果我们尝试使用点表示法访问 Album 类型的 title 属性,TypeScript 将抛出一个错误:

typescript
type AlbumTitle = Album.title;
// 无法访问 'Album.title',因为 'Album' 是类型,而不是命名空间。你是想要使用 'Album["title"]' 来获取 'Album' 中 'title' 属性的类型吗?

在这种情况下,错误消息有一个有用的建议:使用 Album["title"] 来访问 Album 类型中 title 属性的类型:

typescript
type AlbumTitle = Album['title'];

type AlbumTitle = string;

使用这种索引访问语法,AlbumTitle 类型等同于 string,因为这是 Album 接口中 title 属性的类型。

同样的方法可以用于从元组中提取类型,其中索引用于访问元组中特定元素的类型:

typescript
type AlbumTuple = [string, string, number];
type AlbumTitle = AlbumTuple[0];

同样,AlbumTitle 将是一个 string 类型,因为这是 AlbumTuple 中第一个元素的类型。

链接多个索引访问类型

索引访问类型可以链接在一起以访问嵌套属性。这在处理具有嵌套结构的复杂类型时很有用。

例如,我们可以使用索引访问类型从 Album 类型中的 artist 属性中提取 name 属性的类型:

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

type ArtistName = Album['artist']['name'];

在这种情况下,ArtistName 类型将等同于 string,因为这是 artist 对象中 name 属性的类型。

将联合类型传递给索引访问类型

如果你想访问类型的多个属性,你可能会尝试创建一个包含多个索引访问的联合类型:

typescript
interface Album {
  title: string;
  isSingle: boolean;
  releaseYear: number;
}

type AlbumPropertyTypes =
  | Album['title']
  | Album['isSingle']
  | Album['releaseYear'];

这种方法有效,但你可以做得更好 - 你可以直接将联合类型传递给索引访问类型:

typescript
type AlbumPropertyTypes = Album['title' | 'isSingle' | 'releaseYear'];

type AlbumPropertyTypes = string | number | boolean;

这是一种更简洁的实现相同结果的方式。

使用 keyof 获取对象的值

事实上,你可能已经注意到我们在这里有另一个减少重复的机会。我们可以使用 keyofAlbum 类型中提取键,并将它们用作联合类型:

typescript
type AlbumPropertyTypes = Album[keyof Album];

type AlbumPropertyTypes = string | number | boolean;

当你想要提取对象类型中的所有值时,这是一个很好的模式。keyof Obj 将给你 Obj 中所有键的联合,而 Obj[keyof Obj] 将给你 Obj 中所有值的联合。

使用 as const 实现 JavaScript 风格的枚举

在我们关于 TypeScript 专有特性的章节中,我们研究了 enum 关键字。我们看到 enum 是创建一组命名常量的强大方式,但它也有一些缺点。

现在我们有了所有可用的工具,可以看到在 TypeScript 中创建类似枚举结构的另一种方法。

首先,让我们使用我们在关于可变性的章节中看到的 as const 断言。这强制对象被视为只读,并为其属性推断出字面量类型:

typescript
const albumTypes = {
  CD: 'cd',
  VINYL: 'vinyl',
  DIGITAL: 'digital'
} as const;

现在我们可以使用 keyoftypeofalbumTypes 派生我们需要的类型。例如,我们可以使用 keyof 获取键:

typescript
type UppercaseAlbumType = keyof typeof albumTypes; // "CD" | "VINYL" | "DIGITAL"

我们也可以使用 Obj[keyof Obj] 获取值:

typescript
type AlbumType = (typeof albumTypes)[keyof typeof albumTypes]; // "cd" | "vinyl" | "digital"

现在我们可以使用 AlbumType 类型来确保函数只接受 albumTypes 中的值:

typescript
function getAlbumType(type: AlbumType) {
  // ...
}

这种方法有时被称为"POJO",或"普通旧 JavaScript 对象"。虽然需要一些 TypeScript 魔法来设置类型,但结果简单易懂且易于使用。

现在让我们将其与 enum 方法进行比较。

枚举要求你传递枚举值

我们的 getAlbumType 函数的行为与接受枚举的函数不同。因为 AlbumType 只是字符串的联合,我们可以传递原始字符串给 getAlbumType。但如果我们传递不正确的字符串,TypeScript 将显示错误:

typescript
getAlbumType(albumTypes.CD); // 没有错误
getAlbumType('vinyl'); // 没有错误
getAlbumType('cassette');
// 类型 '"cassette"' 不能赋值给类型 'AlbumType'。

这是一种取舍。使用 enum,你必须传递枚举值,这更明确。使用我们的 as const 方法,你可以传递原始字符串。这可能会使重构变得有点困难。

枚举必须被导入

enum 的另一个缺点是它们必须被导入到你所在的模块中才能使用:

typescript
import { AlbumType } from './enums';

getAlbumType(AlbumType.CD);

使用我们的 as const 方法,我们不需要导入任何东西。我们可以传递原始字符串:

typescript
getAlbumType('cd');

枚举的拥护者会认为导入枚举是件好事,因为它清楚地表明枚举来自何处,并使重构更容易。

枚举是名义型的

enum 和我们的 as const 方法之间最大的区别之一是 enum 是名义型的,而我们的 as const 方法是结构型的。

这意味着使用 enum,类型是基于枚举的名称。这意味着来自不同枚举的具有相同值的枚举不兼容:

typescript
enum AlbumType {
  CD = 'cd',
  VINYL = 'vinyl',
  DIGITAL = 'digital'
}

enum MediaType {
  CD = 'cd',
  VINYL = 'vinyl',
  DIGITAL = 'digital'
}

getAlbumType(AlbumType.CD);
getAlbumType(MediaType.CD);
// 类型 'MediaType.CD' 不能赋值给类型 'AlbumType'。

如果你习惯于其他语言中的枚举,这可能是你所期望的。但对于习惯于 JavaScript 的开发者来说,这可能会令人惊讶。

使用 POJO,值的来源并不重要。如果两个 POJO 有相同的值,它们是兼容的:

typescript
const albumTypes = {
  CD: 'cd',
  VINYL: 'vinyl',
  DIGITAL: 'digital'
} as const;

const mediaTypes = {
  CD: 'cd',
  VINYL: 'vinyl',
  DIGITAL: 'digital'
} as const;

getAlbumType(albumTypes.CD); // 没有错误
getAlbumType(mediaTypes.CD); // 没有错误

这是一种取舍。名义型可以更明确并帮助捕获 bug,但也可能更具限制性,更难以使用。

你应该使用哪种方法?

enum 方法更加明确,可以帮助你重构代码。它对来自其他语言的开发者也更熟悉。

as const 方法更加灵活,更容易使用。对 JavaScript 开发者来说也更熟悉。

总的来说,如果你与习惯使用 enum 的团队一起工作,你应该使用 enum。但如果我现在开始一个项目,我会使用 as const 而不是 enum

从函数派生类型

到目前为止,我们只研究了从对象和数组派生类型。但从函数派生类型可以帮助解决 TypeScript 中的一些常见问题。

Parameters

Parameters 工具类型从给定的函数类型中提取参数,并将它们作为元组返回。

例如,这个 sellAlbum 函数接受一个 Album、一个价格和一个数量,然后返回一个表示总价格的数字:

typescript
function sellAlbum(album: Album, price: number, quantity: number) {
  return price * quantity;
}

使用 Parameters 工具类型,我们可以从 sellAlbum 函数中提取参数并将它们分配给一个新类型:

typescript
type SellAlbumParams = Parameters<typeof sellAlbum>;

type SellAlbumParams = [album: Album, price: number, quantity: number];

请注意,我们需要使用 typeofsellAlbum 函数创建类型。直接将 sellAlbum 传递给 Parameters 不会起作用,因为 sellAlbum 是一个值而不是类型:

typescript
type SellAlbumParams = Parameters<sellAlbum>;
// 'sellAlbum' 是一个值,但在这里被用作类型。你是想用 'typeof sellAlbum' 吗?

这个 SellAlbumParams 类型是一个元组类型,它包含了来自 sellAlbum 函数的 Albumpricequantity 参数。

如果我们需要访问 SellAlbumParams 类型中的特定参数,我们可以使用索引访问类型:

typescript
type Price = SellAlbumParams[1]; // number

ReturnType

ReturnType 工具类型从给定函数中提取返回类型:

typescript
type SellAlbumReturn = ReturnType<typeof sellAlbum>;

type SellAlbumReturn = number;

在这种情况下,SellAlbumReturn 类型是一个数字,它是从 sellAlbum 函数派生的。

Awaited

在本书的早期部分,我们在处理异步代码时使用了 Promise 类型。

Awaited 工具类型用于解包 Promise 类型并提供解析值的类型。可以把它看作是类似于使用 await.then() 方法的快捷方式。

这对于派生异步函数的返回类型特别有用。

要使用它,可以将 Promise 类型传递给 Awaited,它将返回解析值的类型:

typescript
type AlbumPromise = Promise<Album>;

type AlbumResolved = Awaited<AlbumPromise>;

为什么要从函数派生类型?

从函数派生类型一开始可能看起来不是很有用。毕竟,如果我们控制这些函数,那么我们可以自己编写类型,并根据需要重复使用它们:

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

const sellAlbum = (album: Album, price: number, quantity: number) => {
  return price * quantity;
};

没有理由在 sellAlbum 上使用 ParametersReturnType,因为我们自己定义了 Album 类型和返回类型。

但是,如果是你不控制的函数呢?

一个常见的例子是第三方库。库可能导出一个你可以使用的函数,但可能不导出相应的类型。我最近遇到的一个例子是 @monaco-editor/react 库中的一个类型。

jsx
import { Editor } from '@monaco-editor/react';

// 这是 JSX 组件,对于我们的目的等同于...
<Editor
  onMount={editor => {
    // ...
  }}
/>;

// ...直接用一个对象调用函数
Editor({
  onMount: editor => {
    // ...
  }
});

在这种情况下,我想知道 editor 的类型,以便在其他地方的函数中重用它。但 @monaco-editor/react 库没有导出它的类型。

首先,我提取了组件期望的对象类型:

typescript
type EditorProps = Parameters<typeof Editor>[0];

然后,我使用索引访问类型提取 onMount 属性的类型:

typescript
type OnMount = EditorProps['onMount'];

最后,我从 OnMount 类型中提取第一个参数,得到 editor 的类型:

typescript
type Editor = Parameters<OnMount>[0];

这使我能够在代码的其他地方的函数中重用 Editor 类型。

通过将索引访问类型与 TypeScript 的工具类型结合使用,你可以解决第三方库的限制,并确保你的类型与你使用的函数保持同步。

转换派生类型

在上一节中,我们研究了如何从你不控制的函数派生类型。有时,你也需要对不控制的类型做同样的事情。

Exclude

Exclude 工具类型用于从联合中移除类型。让我们想象我们有一个专辑可能处于的不同状态的联合:

typescript
type AlbumState =
  | {
      type: 'released';
      releaseDate: string;
    }
  | {
      type: 'recording';
      studio: string;
    }
  | {
      type: 'mixing';
      engineer: string;
    };

我们想创建一个表示非"released"状态的类型。我们可以使用 Exclude 工具类型来实现这一点:

typescript
type UnreleasedState = Exclude<AlbumState, { type: 'released' }>;

type UnreleasedState =
  | {
      type: 'recording';
      studio: string;
    }
  | {
      type: 'mixing';
      engineer: string;
    };

在这种情况下,UnreleasedState 类型是录音和混音状态的联合,这些是非"released"状态。Exclude 过滤出联合中任何类型为 released 的成员。

我们也可以通过检查 releaseDate 属性来实现:

typescript
type UnreleasedState = Exclude<AlbumState, { releaseDate: string }>;

这是因为 Exclude 通过模式匹配工作。它将从联合中移除任何匹配你提供的模式的类型。

这意味着我们可以用它来从联合中移除所有字符串:

typescript
type Example = 'a' | 'b' | 1 | 2;

type Numbers = Exclude<Example, string>;

type Numbers = 1 | 2;

NonNullable

NonNullable 用于从类型中移除 nullundefined。这在从部分对象中提取类型时很有用:

typescript
interface Album {
  artist?: {
    name: string;
  };
}

type Artist = NonNullable<Album['artist']>;

interface Artist {
  name: string;
}

这的操作类似于 Exclude

typescript
type Artist = Exclude<Album['artist'], null | undefined>;

NonNullable 更明确,更容易阅读。

Extract

ExtractExclude 的反义词。它用于从联合中提取类型。例如,我们可以使用 ExtractAlbumState 类型中提取录音状态:

typescript
type RecordingState = Extract<AlbumState, { type: 'recording' }>;

interface RecordingState {
  type: 'recording';
  studio: string;
}

当你想从你不控制的联合中提取特定类型时,这很有用。

Exclude 类似,Extract 也通过模式匹配工作。它将从联合中提取任何匹配你提供的模式的类型。

这意味着,要反转我们之前的 Extract 例子,我们可以使用它来从联合中提取所有字符串:

typescript
type Example = 'a' | 'b' | 1 | 2 | true | false;

type Strings = Extract<Example, string>;

type Strings = 'a' | 'b';

值得注意的是 Exclude/ExtractOmit/Pick 之间的相似之处。一个常见的错误是认为你可以从联合中 Pick,或在对象上使用 Exclude。这里有一个小表格帮助你记忆:

名称用于行为示例
Exclude联合类型排除成员Exclude<'a' | 1, string>
Extract联合类型提取成员Extract<'a' | 1, string>
Omit对象排除属性Omit<UserObj, 'id'>
Pick对象提取属性Pick<UserObj, 'id'>

派生与解耦

通过这些章节中的工具,我们现在知道了如何从各种来源派生类型:函数、对象和类型。但在派生类型时,需要考虑一个权衡:耦合。

当你从一个源派生类型时,你将派生类型与该源耦合。如果你从另一个派生类型派生一个类型,这可能会在整个应用程序中创建难以管理的长耦合链。

何时解耦有意义

让我们想象在 db.ts 文件中有一个 User 类型:

typescript
export interface User {
  id: string;
  name: string;
  imageUrl: string;
  email: string;
}

假设我们正在使用像 React、Vue 或 Svelte 这样的基于组件的框架。我们有一个 AvatarImage 组件,它渲染用户的图像。我们可以直接传入 User 类型:

jsx
import { User } from "./db";

export const AvatarImage = (props: { user: User }) => {
  return <img src={props.user.imageUrl} alt={props.user.name} />;
};

但事实证明,我们只使用 User 类型中的 imageUrlname 属性。让你的函数和组件只要求它们

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)