Skip to content

TypeScript 派生类型

参考资料

来源: Deriving Types - June 28, 2024

作者: Matt Pocock

译者: SenaoXi

版权声明

翻译与转载须知

本译文仅供教育和信息交流之用。所有知识产权(包括版权)均归原作者和/或出版商所有。本译文在保持原文内容完整性的同时,旨在使其更易于中文读者理解。

修改声明

  • 本文为原文的完整忠实译本,未进行任何实质性修改。
  • 本译文包含为提升中文读者清晰度而做的微小调整,同时保留了所有基本信息和观点。
  • 标有 [†] 的部分包含译者为提供文化或技术背景而添加的补充说明。

权利保留

如果您是版权所有者,并认为本译文超出了合理使用范围,请通过 邮箱 与我们联系。我们致力于尊重知识产权,并将及时处理任何正当关切。

技术背景

探索 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 属性。让你的函数和组件只要求它们

根据 CC BY-SA 4.0 许可证发布。 (95ee605)