TypeScript 派生类型 
版权声明
翻译与转载须知:
本译文仅供教育和信息交流之用。所有知识产权(包括版权)均归原作者和/或出版商所有。本译文在保持原文内容完整性的同时,旨在使其更易于中文读者理解。
修改声明:
- 本文为原文的完整忠实译本,未进行任何实质性修改。
- 本译文包含为提升中文读者清晰度而做的微小调整,同时保留了所有基本信息和观点。
- 标有 [†] 的部分包含译者为提供文化或技术背景而添加的补充说明。
权利保留:
如果您是版权所有者,并认为本译文超出了合理使用范围,请通过 邮箱 与我们联系。我们致力于尊重知识产权,并将及时处理任何正当关切。
技术背景 
探索 TypeScript 的高级类型派生技术:keyof、typeof、索引访问类型,以及用 as const 实现枚举。
编写可维护代码的一个常见建议是"保持代码 DRY",或更明确地说,"不要重复自己"(Don't Repeat Yourself)。
在 JavaScript 中实现这一点的方法之一是将重复的代码提取到函数或变量中。这些函数和变量可以被重用、组合和以不同方式结合来创建新的功能。
在 TypeScript 中,我们可以将相同的原则应用于类型。
在本节中,我们将研究如何从其他类型派生类型。这让我们能够减少代码中的重复,并为类型创建单一的事实来源。
这使得我们可以在一个类型中进行更改,并让这些更改传播到整个应用程序中,而无需手动更新每个实例。
我们甚至会研究如何从值派生类型,以便我们的类型始终代表应用程序的运行时行为。
派生类型 
派生类型是依赖于或继承自另一种类型结构的类型。我们可以使用到目前为止学习的一些工具来创建派生类型。
我们可以使用 interface extends 使一个接口继承另一个接口:
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}
interface AlbumDetails extends Album {
  genre: string;
}AlbumDetails 继承了 Album 的所有属性。这意味着对 Album 的任何更改都会传递到 AlbumDetails。AlbumDetails 是从 Album 派生的。
另一个例子是联合类型。
interface Triangle {
  type: 'triangle';
  sideLength: number;
}
interface Rectangle {
  type: 'rectangle';
  width: number;
  height: number;
}
type Shape = Triangle | Rectangle;派生类型表示一种关系。这种关系是单向的。Shape 不能返回并修改 Triangle 或 Rectangle。但是对 Triangle 和 Rectangle 的任何更改都会影响到 Shape。
设计良好的派生类型可以带来巨大的生产力提升。我们可以在一个地方做出更改,并让它们传播到整个应用程序中。这是保持代码 DRY 并充分利用 TypeScript 类型系统的强大方式。
这有一些权衡。我们可以将派生视为一种耦合。如果我们更改了其他类型依赖的类型,我们需要意识到这种更改的影响。我们将在本章末尾更详细地讨论派生与解耦。
现在,让我们看看 TypeScript 提供的一些用于派生类型的工具。
keyof 操作符 
keyof 操作符允许你从对象类型中提取键并创建为联合类型。
从我们熟悉的 Album 类型开始:
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}我们可以使用 keyof Album 得到 "title"、"artist" 和 "releaseYear" 键的联合:
type AlbumKeys = keyof Album; // "title" | "artist" | "releaseYear"由于 keyof 跟踪源的键,对类型做出的任何更改都会自动反映在 AlbumKeys 类型中。
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
  genre: string; // 添加了 'genre'
}
type AlbumKeys = keyof Album; // "title" | "artist" | "releaseYear" | "genre"然后可以使用 AlbumKeys 类型来确保用于访问 Album 中值的键是有效的,如下面这个函数所示:
function getAlbumDetails(album: Album, key: AlbumKeys) {
  return album[key];
}如果传递给 getAlbumDetails 的键不是 Album 的有效键,TypeScript 将显示错误:
getAlbumDetails(album, 'producer');
// 类型 '"producer"' 不能赋值给类型 'keyof Album'。keyof 是从现有类型创建新类型的重要构建块。我们稍后将看到如何将它与 as const 一起使用来构建自己的类型安全枚举。
typeof 操作符 
typeof 操作符允许你从值中提取类型。
假设我们有一个 albumSales 对象,其中包含一些专辑标题键和销售统计数据:
const albumSales = {
  'Kind of Blue': 5000000,
  'A Love Supreme': 1000000,
  'Mingus Ah Um': 3000000
};我们可以使用 typeof 提取 albumSales 的类型,这将把它转变为一个类型,其中包含原始键作为字符串和它们的推断类型作为值:
type AlbumSalesType = typeof albumSales;
interface AlbumSalesType {
  'Kind of Blue': number;
  'A Love Supreme': number;
  'Mingus Ah Um': number;
}现在我们有了 AlbumSalesType 类型,我们可以从中创建另一个派生类型。例如,我们可以使用 keyof 提取 albumSales 对象的键:
type AlbumTitles = keyof AlbumSalesType; // "Kind of Blue" | "A Love Supreme" | "Mingus Ah Um"一种常见的模式是结合使用 keyof 和 typeof 从现有对象类型的键和值创建新类型:
type AlbumTitles = keyof typeof albumSales;我们可以在函数中使用它来确保 title 参数是 albumSales 的有效键,例如查找特定专辑的销售情况:
function getSales(title: AlbumTitles) {
  return albumSales[title];
}值得注意的是,typeof 不同于运行时使用的 typeof 操作符。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 操作符:
type Album = {
  title: string;
  artist: string;
  releaseYear: number;
};
const album = valueof Album; // 不起作用!TypeScript 的类型在运行时会消失,所以没有内置的方法可以从类型创建值。换句话说,你可以从"值世界"移动到"类型世界",但不能反向操作。
索引访问类型 
TypeScript 中的索引访问类型允许你访问另一个类型的属性。这类似于你在运行时访问对象中属性的值的方式,但它在类型级别上操作。
例如,我们可以使用索引访问类型从 Album 中提取 title 属性的类型:
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}如果我们尝试使用点表示法访问 Album 类型的 title 属性,TypeScript 将抛出一个错误:
type AlbumTitle = Album.title;
// 无法访问 'Album.title',因为 'Album' 是类型,而不是命名空间。你是想要使用 'Album["title"]' 来获取 'Album' 中 'title' 属性的类型吗?在这种情况下,错误消息有一个有用的建议:使用 Album["title"] 来访问 Album 类型中 title 属性的类型:
type AlbumTitle = Album['title'];
type AlbumTitle = string;使用这种索引访问语法,AlbumTitle 类型等同于 string,因为这是 Album 接口中 title 属性的类型。
同样的方法可以用于从元组中提取类型,其中索引用于访问元组中特定元素的类型:
type AlbumTuple = [string, string, number];
type AlbumTitle = AlbumTuple[0];同样,AlbumTitle 将是一个 string 类型,因为这是 AlbumTuple 中第一个元素的类型。
链接多个索引访问类型 
索引访问类型可以链接在一起以访问嵌套属性。这在处理具有嵌套结构的复杂类型时很有用。
例如,我们可以使用索引访问类型从 Album 类型中的 artist 属性中提取 name 属性的类型:
interface Album {
  title: string;
  artist: {
    name: string;
  };
}
type ArtistName = Album['artist']['name'];在这种情况下,ArtistName 类型将等同于 string,因为这是 artist 对象中 name 属性的类型。
将联合类型传递给索引访问类型 
如果你想访问类型的多个属性,你可能会尝试创建一个包含多个索引访问的联合类型:
interface Album {
  title: string;
  isSingle: boolean;
  releaseYear: number;
}
type AlbumPropertyTypes =
  | Album['title']
  | Album['isSingle']
  | Album['releaseYear'];这种方法有效,但你可以做得更好 - 你可以直接将联合类型传递给索引访问类型:
type AlbumPropertyTypes = Album['title' | 'isSingle' | 'releaseYear'];
type AlbumPropertyTypes = string | number | boolean;这是一种更简洁的实现相同结果的方式。
使用 keyof 获取对象的值 
事实上,你可能已经注意到我们在这里有另一个减少重复的机会。我们可以使用 keyof 从 Album 类型中提取键,并将它们用作联合类型:
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 断言。这强制对象被视为只读,并为其属性推断出字面量类型:
const albumTypes = {
  CD: 'cd',
  VINYL: 'vinyl',
  DIGITAL: 'digital'
} as const;现在我们可以使用 keyof 和 typeof 从 albumTypes 派生我们需要的类型。例如,我们可以使用 keyof 获取键:
type UppercaseAlbumType = keyof typeof albumTypes; // "CD" | "VINYL" | "DIGITAL"我们也可以使用 Obj[keyof Obj] 获取值:
type AlbumType = (typeof albumTypes)[keyof typeof albumTypes]; // "cd" | "vinyl" | "digital"现在我们可以使用 AlbumType 类型来确保函数只接受 albumTypes 中的值:
function getAlbumType(type: AlbumType) {
  // ...
}这种方法有时被称为"POJO",或"普通旧 JavaScript 对象"。虽然需要一些 TypeScript 魔法来设置类型,但结果简单易懂且易于使用。
现在让我们将其与 enum 方法进行比较。
枚举要求你传递枚举值 
我们的 getAlbumType 函数的行为与接受枚举的函数不同。因为 AlbumType 只是字符串的联合,我们可以传递原始字符串给 getAlbumType。但如果我们传递不正确的字符串,TypeScript 将显示错误:
getAlbumType(albumTypes.CD); // 没有错误
getAlbumType('vinyl'); // 没有错误
getAlbumType('cassette');
// 类型 '"cassette"' 不能赋值给类型 'AlbumType'。这是一种取舍。使用 enum,你必须传递枚举值,这更明确。使用我们的 as const 方法,你可以传递原始字符串。这可能会使重构变得有点困难。
枚举必须被导入 
enum 的另一个缺点是它们必须被导入到你所在的模块中才能使用:
import { AlbumType } from './enums';
getAlbumType(AlbumType.CD);使用我们的 as const 方法,我们不需要导入任何东西。我们可以传递原始字符串:
getAlbumType('cd');枚举的拥护者会认为导入枚举是件好事,因为它清楚地表明枚举来自何处,并使重构更容易。
枚举是名义型的 
enum 和我们的 as const 方法之间最大的区别之一是 enum 是名义型的,而我们的 as const 方法是结构型的。
这意味着使用 enum,类型是基于枚举的名称。这意味着来自不同枚举的具有相同值的枚举不兼容:
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 有相同的值,它们是兼容的:
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、一个价格和一个数量,然后返回一个表示总价格的数字:
function sellAlbum(album: Album, price: number, quantity: number) {
  return price * quantity;
}使用 Parameters 工具类型,我们可以从 sellAlbum 函数中提取参数并将它们分配给一个新类型:
type SellAlbumParams = Parameters<typeof sellAlbum>;
type SellAlbumParams = [album: Album, price: number, quantity: number];请注意,我们需要使用 typeof 从 sellAlbum 函数创建类型。直接将 sellAlbum 传递给 Parameters 不会起作用,因为 sellAlbum 是一个值而不是类型:
type SellAlbumParams = Parameters<sellAlbum>;
// 'sellAlbum' 是一个值,但在这里被用作类型。你是想用 'typeof sellAlbum' 吗?这个 SellAlbumParams 类型是一个元组类型,它包含了来自 sellAlbum 函数的 Album、price 和 quantity 参数。
如果我们需要访问 SellAlbumParams 类型中的特定参数,我们可以使用索引访问类型:
type Price = SellAlbumParams[1]; // numberReturnType 
ReturnType 工具类型从给定函数中提取返回类型:
type SellAlbumReturn = ReturnType<typeof sellAlbum>;
type SellAlbumReturn = number;在这种情况下,SellAlbumReturn 类型是一个数字,它是从 sellAlbum 函数派生的。
Awaited 
在本书的早期部分,我们在处理异步代码时使用了 Promise 类型。
Awaited 工具类型用于解包 Promise 类型并提供解析值的类型。可以把它看作是类似于使用 await 或 .then() 方法的快捷方式。
这对于派生异步函数的返回类型特别有用。
要使用它,可以将 Promise 类型传递给 Awaited,它将返回解析值的类型:
type AlbumPromise = Promise<Album>;
type AlbumResolved = Awaited<AlbumPromise>;为什么要从函数派生类型? 
从函数派生类型一开始可能看起来不是很有用。毕竟,如果我们控制这些函数,那么我们可以自己编写类型,并根据需要重复使用它们:
interface Album {
  title: string;
  artist: string;
  releaseYear: number;
}
const sellAlbum = (album: Album, price: number, quantity: number) => {
  return price * quantity;
};没有理由在 sellAlbum 上使用 Parameters 或 ReturnType,因为我们自己定义了 Album 类型和返回类型。
但是,如果是你不控制的函数呢?
一个常见的例子是第三方库。库可能导出一个你可以使用的函数,但可能不导出相应的类型。我最近遇到的一个例子是 @monaco-editor/react 库中的一个类型。
import { Editor } from '@monaco-editor/react';
// 这是 JSX 组件,对于我们的目的等同于...
<Editor
  onMount={editor => {
    // ...
  }}
/>;
// ...直接用一个对象调用函数
Editor({
  onMount: editor => {
    // ...
  }
});在这种情况下,我想知道 editor 的类型,以便在其他地方的函数中重用它。但 @monaco-editor/react 库没有导出它的类型。
首先,我提取了组件期望的对象类型:
type EditorProps = Parameters<typeof Editor>[0];然后,我使用索引访问类型提取 onMount 属性的类型:
type OnMount = EditorProps['onMount'];最后,我从 OnMount 类型中提取第一个参数,得到 editor 的类型:
type Editor = Parameters<OnMount>[0];这使我能够在代码的其他地方的函数中重用 Editor 类型。
通过将索引访问类型与 TypeScript 的工具类型结合使用,你可以解决第三方库的限制,并确保你的类型与你使用的函数保持同步。
转换派生类型 
在上一节中,我们研究了如何从你不控制的函数派生类型。有时,你也需要对不控制的类型做同样的事情。
Exclude 
Exclude 工具类型用于从联合中移除类型。让我们想象我们有一个专辑可能处于的不同状态的联合:
type AlbumState =
  | {
      type: 'released';
      releaseDate: string;
    }
  | {
      type: 'recording';
      studio: string;
    }
  | {
      type: 'mixing';
      engineer: string;
    };我们想创建一个表示非"released"状态的类型。我们可以使用 Exclude 工具类型来实现这一点:
type UnreleasedState = Exclude<AlbumState, { type: 'released' }>;
type UnreleasedState =
  | {
      type: 'recording';
      studio: string;
    }
  | {
      type: 'mixing';
      engineer: string;
    };在这种情况下,UnreleasedState 类型是录音和混音状态的联合,这些是非"released"状态。Exclude 过滤出联合中任何类型为 released 的成员。
我们也可以通过检查 releaseDate 属性来实现:
type UnreleasedState = Exclude<AlbumState, { releaseDate: string }>;这是因为 Exclude 通过模式匹配工作。它将从联合中移除任何匹配你提供的模式的类型。
这意味着我们可以用它来从联合中移除所有字符串:
type Example = 'a' | 'b' | 1 | 2;
type Numbers = Exclude<Example, string>;
type Numbers = 1 | 2;NonNullable 
NonNullable 用于从类型中移除 null 和 undefined。这在从部分对象中提取类型时很有用:
interface Album {
  artist?: {
    name: string;
  };
}
type Artist = NonNullable<Album['artist']>;
interface Artist {
  name: string;
}这的操作类似于 Exclude:
type Artist = Exclude<Album['artist'], null | undefined>;但 NonNullable 更明确,更容易阅读。
Extract 
Extract 是 Exclude 的反义词。它用于从联合中提取类型。例如,我们可以使用 Extract 从 AlbumState 类型中提取录音状态:
type RecordingState = Extract<AlbumState, { type: 'recording' }>;
interface RecordingState {
  type: 'recording';
  studio: string;
}当你想从你不控制的联合中提取特定类型时,这很有用。
与 Exclude 类似,Extract 也通过模式匹配工作。它将从联合中提取任何匹配你提供的模式的类型。
这意味着,要反转我们之前的 Extract 例子,我们可以使用它来从联合中提取所有字符串:
type Example = 'a' | 'b' | 1 | 2 | true | false;
type Strings = Extract<Example, string>;
type Strings = 'a' | 'b';值得注意的是 Exclude/Extract 和 Omit/Pick 之间的相似之处。一个常见的错误是认为你可以从联合中 Pick,或在对象上使用 Exclude。这里有一个小表格帮助你记忆:
| 名称 | 用于 | 行为 | 示例 | 
|---|---|---|---|
| Exclude | 联合类型 | 排除成员 | Exclude<'a' | 1, string> | 
| Extract | 联合类型 | 提取成员 | Extract<'a' | 1, string> | 
| Omit | 对象 | 排除属性 | Omit<UserObj, 'id'> | 
| Pick | 对象 | 提取属性 | Pick<UserObj, 'id'> | 
派生与解耦 
通过这些章节中的工具,我们现在知道了如何从各种来源派生类型:函数、对象和类型。但在派生类型时,需要考虑一个权衡:耦合。
当你从一个源派生类型时,你将派生类型与该源耦合。如果你从另一个派生类型派生一个类型,这可能会在整个应用程序中创建难以管理的长耦合链。
何时解耦有意义 
让我们想象在 db.ts 文件中有一个 User 类型:
export interface User {
  id: string;
  name: string;
  imageUrl: string;
  email: string;
}假设我们正在使用像 React、Vue 或 Svelte 这样的基于组件的框架。我们有一个 AvatarImage 组件,它渲染用户的图像。我们可以直接传入 User 类型:
import { User } from "./db";
export const AvatarImage = (props: { user: User }) => {
  return <img src={props.user.imageUrl} alt={props.user.name} />;
};但事实证明,我们只使用 User 类型中的 imageUrl 和 name 属性。让你的函数和组件只要求它们
 XiSenao
 XiSenao