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]; // number
ReturnType
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
属性。让你的函数和组件只要求它们