TypeScript 派生类型
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 的高级类型派生技术: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
属性。让你的函数和组件只要求它们