TypeScript 工具函数开发
通过实践练习学习 TypeScript 泛型函数、类型参数、谓词和函数重载。复杂类型操作和调试。
通常认为 TypeScript 有两个复杂性级别。
一方面,你进行库的开发。在这里,你会利用 TypeScript 中许多最晦涩但强大的特性。你需要条件类型、映射类型、泛型等等,来创建一个足够灵活以便在各种场景中使用的库。
另一方面,你进行应用程序开发。在这里,你主要关心的是确保代码类型安全。你希望确保类型能够反映应用程序中正在发生的事情。任何复杂类型都封装在你使用的库中。你需要了解 TypeScript 的使用方式,但不太需要使用其高级特性。
这是大多数 TypeScript 社区成员使用的经验法则。"对于应用程序代码来说太复杂了"。"你只会在库中需要它"。但是,还有一个经常被忽视的第三个级别:/utils 文件夹。
如果你的应用程序变得足够大,你会开始将常见的模式提取到一组可重用的函数中。这些函数,如 groupBy、debounce 和 retry,可能在大型应用程序中被使用数百次。它们就像应用程序范围内的迷你库。
理解如何构建这些类型的函数可以为你的团队节省大量时间。捕获常见模式意味着你的代码变得更易于维护,构建速度也更快。
在本章中,我们将介绍如何构建这些函数。我们将从泛型函数开始,然后介绍类型谓词、断言函数和函数重载。
泛型函数
我们已经看到,在 TypeScript 中,函数不仅可以接收值作为参数,还可以接收类型。在这里,我们向 new Set() 传递了一个值 和一个类型:
typescript
const set = new Set<number>([1, 2, 3]);
// ^^^^^^^^ ^^^^^^^^^
// 类型 值
我们在尖括号中传递类型,在圆括号中传递值。这是因为 new Set() 是一个泛型函数。不能接收类型的函数是常规函数,比如 JSON.parse:
typescript
const obj = JSON.parse<{ hello: string }>('{"hello": "world"}');
// 类型参数应为 0 个,但获得了 1 个。2558
在这里,TypeScript 告诉我们 JSON.parse 不接受类型参数,因为它不是泛型的。
是什么让函数成为泛型?
如果函数声明了一个类型参数,那么它就是泛型的。这是一个带有类型参数 T 的泛型函数:
r
function identity<T>(arg: T): T {
// ^^^ 类型参数
return arg;
}
我们可以使用 function 关键字,或者使用箭头函数语法:
ini
const identity = <T>(arg: T): T => arg;
我们甚至可以将泛型函数声明为一个类型:
ini
type Identity = <T>(arg: T) => T;
const identity: Identity = (arg) => arg;
现在,我们可以向 identity 传递一个类型参数:
ini
identity<number>(42);
泛型函数类型别名 vs 泛型类型
非常重要的一点是,不要将泛型类型的语法与泛型函数的类型别名的语法混淆。对于未经训练的人来说,它们看起来非常相似。以下是区别:
typescript
// 泛型函数的类型别名
type Identity = <T>(arg: T) => T;
// ^^^
// 类型参数属于函数
// 泛型类型
type Identity<T> = (arg: T) => T;
// ^^^
// 类型参数属于类型
关键在于类型参数的位置。如果它附加在类型名称上,它就是一个泛型类型。如果它附加在函数的圆括号上,它就是一个泛型函数的类型别名。
当我们不传入类型参数时会发生什么?
当我们研究泛型类型时,我们看到 TypeScript 要求你在使用泛型类型时传入所有类型参数:
ini
type StringArray = Array<string>;
type AnyArray = Array;
// 泛型类型 'Array<T>' 需要 1 个类型参数。2314
这对于泛型函数来说并非如此。如果你不向泛型函数传递类型参数,TypeScript 不会报错:
csharp
function identity<T>(arg: T): T {
return arg;
}
const result = identity(42); // 没有错误!
这是为什么呢?嗯,这是泛型函数的一个特性,也使它们成为我最喜欢的 TypeScript 工具。如果你不传递类型参数,TypeScript 将尝试从函数的运行时参数中推断它。
我们上面的 identity 函数只是接收一个参数并返回它。我们已经在运行时参数中引用了类型参数:arg: T。这意味着如果我们不传入类型参数,T 将从 arg 的类型中推断出来。
所以,result 的类型将是 42:
ini
const result = identity(42);
// const result: 42
这意味着每次调用该函数时,它都可能返回不同的类型:
ini
const result1 = identity("hello");
// const result1: "hello"
const result2 = identity({ hello: "world" });
// const result2: {
// hello: string;
// }
const result3 = identity([1, 2, 3]);
// const result3: number[]
这种能力意味着你的函数可以理解它们正在处理的类型,并相应地调整它们的建议和错误。这是 TypeScript 最强大和灵活之处。
指定类型优于推断类型
让我们回到指定类型参数而不是推断它们。如果你传递的类型参数与运行时参数冲突会发生什么?
让我们用我们的 identity 函数试试:
sql
const result = identity<string>(42);
// 类型 'number' 的参数不能赋给类型 'string' 的参数。2345
在这里,TypeScript 告诉我们 42 不是一个 string。这是因为我们明确告诉 TypeScript T 应该是一个 string,这与运行时参数冲突。
传递类型参数是给 TypeScript 的一个指令,用于覆盖推断。如果你传入一个类型参数,TypeScript 会将其用作事实的来源。如果你不传入,TypeScript 会将运行时参数的类型用作事实的来源。
没有所谓的"一个泛型"
这里快速说明一下术语。TypeScript 的"泛型"以难以理解著称。我认为很大程度上是因为人们使用"泛型"这个词的方式。
很多人认为"泛型"是 TypeScript 的一部分。他们把它看作一个名词。如果你问别人"这段代码中的'泛型'在哪里?":
ini
const identity = <T>(arg: T) => arg;
他们可能会指向 <T>。其他人可能会将下面的代码描述为"向 Set 传递一个'泛型'":
typescript
const set = new Set<number>([1, 2, 3]);
这种术语会变得非常混乱。相反,我更喜欢将它们分成不同的术语:
- 类型参数 (Type Parameter):
identity<T>中的<T>。 - 类型实参 (Type Argument):传递给
Set<number>的number。 - 泛型类/函数/类型 (Generic Class/Function/Type):声明了类型参数的类、函数或类型。
当你把泛型分解成这些术语时,理解起来就容易多了。
泛型函数解决的问题
让我们把学到的知识付诸实践。
考虑这个名为 getFirstElement 的函数,它接受一个数组并返回第一个元素:
ini
const getFirstElement = (arr: any[]) => {
return arr[0];
};
这个函数很危险。因为它接受一个 any 类型的数组,这意味着我们从 getFirstElement 中得到的东西也是 any:
ini
const first = getFirstElement([1, 2, 3]);
// const first: any
正如我们所见,any 可能会在你的代码中造成严重破坏。任何使用此函数的人都会在不知不觉中放弃 TypeScript 的类型安全。那么,我们该如何修复它呢?
我们需要 TypeScript 理解我们传入的数组的类型,并用它来类型化返回的内容。我们需要使 getFirstElement 成为泛型:
为此,我们将在函数的参数列表前添加一个类型参数 TMember,然后使用 TMember[] 作为数组的类型:
ini
const getFirstElement = <TMember>(arr: TMember[]) => {
return arr[0];
};
就像泛型函数一样,通常用 T 作为类型参数的前缀,以区别于普通类型。
现在当我们调用 getFirstElement 时,TypeScript 会根据我们传入的参数推断出 TMember 的类型:
ini
const firstNumber = getFirstElement([1, 2, 3]);
// const firstNumber: number
const firstString = getFirstElement(["a", "b", "c"]);
// const firstString: string
现在,我们已经使 getFirstElement 类型安全了。我们传入的数组的类型就是我们得到的元素的类型。
调试泛型函数的推断类型
当你使用泛型函数时,可能很难知道 TypeScript 推断出了什么类型。然而,通过仔细地将鼠标悬停,你可以找到答案。
当我们调用 getFirstElement 函数时,我们可以将鼠标悬停在函数名上,看看 TypeScript 推断出了什么:
ini
const first = getFirstElement([1, 2, 3]);
// const getFirstElement: <number>(arr: number[]) => number
我们可以看到,在尖括号内,TypeScript 推断出 TMember 是 number,因为我们传入了一个数字数组。
当你有更复杂的函数和多个类型参数需要调试时,这会很有用。我经常发现自己会在同一个文件中创建临时的函数调用,以查看 TypeScript 推断出了什么。
类型参数默认值
就像泛型类型一样,你可以在泛型函数中为类型参数设置默认值。当函数的运行时参数是可选的时,这会很有用:
typescript
const createSet = <T = string>(arr?: T[]) => {
return new Set(arr);
};
这里,我们将 T 的默认类型设置为 string。这意味着如果我们不传入类型参数,TypeScript 会假定 T 是 string:
vbnet
const defaultSet = createSet();
// const defaultSet: Set<string>
默认值不会对 T 的类型施加约束。这意味着我们仍然可以传入任何我们想要的类型:
ini
const numberSet = createSet<number>([1, 2, 3]);
// const numberSet: Set<number>
如果我们不指定默认值,并且 TypeScript 无法从运行时参数中推断类型,它将默认为 unknown:
javascript
const createSet = <T>(arr?: T[]) => {
return new Set(arr);
};
const unknownSet = createSet();
// const unknownSet: Set<unknown>
这里,我们移除了 T 的默认类型,TypeScript 默认其为 unknown。
约束类型参数
你还可以为泛型函数中的类型参数添加约束。当你希望确保一个类型具有某些属性时,这会很有用。
让我们想象一个 removeId 函数,它接受一个对象并移除 id 属性:
javascript
const removeId = <TObj>(obj: TObj) => {
const { id, ...rest } = obj;
// 属性 'id' 在类型 'unknown' 上不存在。2339
return rest;
};
我们的 TObj 类型参数,在没有约束的情况下使用时,被视为 unknown。这意味着 TypeScript 不知道 id 是否存在于 obj 上。
要解决这个问题,我们可以为 TObj 添加一个约束,确保它具有 id 属性:
typescript
const removeId = <TObj extends { id: unknown }>(obj: TObj) => {
const { id, ...rest } = obj;
return rest;
};
现在,当我们使用 removeId 时,如果我们传入的对象没有 id 属性,TypeScript 会报错:
ini
const result = removeId({ name: "Alice" });
// 对象字面量只能指定已知的属性,而 'name' 在类型 '{ id: unknown; }' 中不存在。2353
但是如果我们传入一个带有 id 属性的对象,TypeScript 会知道 id 已经被移除了:
php
const result = removeId({ id: 1, name: "Alice" });
// const result: Omit<{
// id: number;
// name: string;
// }, "id">
注意 TypeScript 在这里有多聪明。尽管我们没有为 removeId 指定返回类型,TypeScript 还是推断出 result 是一个对象,它拥有输入对象的所有属性,除了 id。
类型谓词
早在第 5 章,当我们学习类型收窄时,就已经接触过类型谓词了。它们用于捕获可重用的逻辑,以收窄变量的类型。
例如,假设我们想在尝试访问某个变量的属性或将其传递给需要 Album 类型的函数之前,确保该变量是一个 Album。
我们可以编写一个 isAlbum 函数,它接收一个输入,并检查所有必需的属性。
typescript
function isAlbum(input: unknown) {
return (
typeof input === "object" &&
input !== null &&
"id" in input &&
"title" in input &&
"artist" in input &&
"year" in input
);
}
如果我们将鼠标悬停在 isAlbum 上,我们可以看到一个相当丑陋的类型签名:
typescript
// 鼠标悬停在 isAlbum 上显示:
function isAlbum(
input: unknown,
): input is object &
Record<"id", unknown> &
Record<"title", unknown> &
Record<"artist", unknown> &
Record<"year", unknown>;
这在技术上是正确的:一个 object 和一堆 Record 之间的大型交叉类型。但这并没有太大帮助。
当我们尝试使用 isAlbum 来收窄一个值的类型时,TypeScript 不会正确推断它:
typescript
// @errors: 18046
function isAlbum(input: unknown) {
return (
typeof input === "object" &&
input !== null &&
"id" in input &&
"title" in input &&
"artist" in input &&
"year" in input
);
}
// ---cut---
const run = (maybeAlbum: unknown) => {
if (isAlbum(maybeAlbum)) {
maybeAlbum.name.toUpperCase();
}
};
要解决这个问题,我们需要向 isAlbum 添加更多的检查,以确保我们正在检查所有属性的类型:
typescript
function isAlbum(input: unknown) {
return (
typeof input === "object" &&
input !== null &&
"id" in input &&
"title" in input &&
"artist" in input &&
"year" in input &&
typeof input.id === "number" &&
typeof input.title === "string" &&
typeof input.artist === "string" &&
typeof input.year === "number"
);
}
这可能感觉过于冗长。我们可以通过添加我们自己的类型谓词来使其更具可读性。
typescript
// 假设 Album 类型已定义
// interface Album {
// id: number;
// title: string;
// artist: string;
// year: number;
// // 可能还有 name 属性,根据后续的 maybeAlbum.name.toUpperCase()
// name?: string; // 或者 title 就是 name,这里假设是 title
// }
function isAlbum(input: unknown): input is Album {
return (
typeof input === "object" &&
input !== null &&
"id" in input &&
"title" in input && // 假设 title 是要大写的属性
"artist" in input &&
"year" in input
// 如果 Album 定义了更严格的类型,这里也应该检查
// 比如 typeof (input as Album).id === "number" 等
);
}
现在,当我们使用 isAlbum 时,TypeScript 将知道值的类型已被收窄为 Album:
typescript
const run = (maybeAlbum: unknown) => {
if (isAlbum(maybeAlbum)) {
// 假设 Album 有 title 属性,并且 title 是我们要操作的
// 如果是 name 属性,isAlbum 和 Album 定义需要对应
(maybeAlbum as Album).title.toUpperCase(); // 没有错误!
// 或者如果 isAlbum 内部已经充分检查了类型
// maybeAlbum.title.toUpperCase();
}
};
对于复杂的类型守卫,这可能更具可读性。
类型谓词可能不安全
编写自己的类型谓词可能有点危险。如果类型谓词不能准确反映正在检查的类型,TypeScript 不会捕获这种差异:
typescript
// 假设 Album 类型已定义
// interface Album { id: number; title: string; ... }
function isAlbum(input: any): input is Album {
return typeof input === "object";
}
在这种情况下,传递给 isAlbum 的任何对象都将被视为 Album,即使它没有必需的属性。这是使用类型谓词时常见的陷阱------重要的是要将它们视为与 as 和 ! 一样不安全。
断言函数
断言函数看起来与类型谓词相似,但它们的用法略有不同。断言函数不是返回一个布尔值来指示值是否属于某个类型,而是在值不符合预期类型时抛出错误。
以下是我们如何将 isAlbum 类型谓词重构为 assertIsAlbum 断言函数:
typescript
// 假设 Album 类型已定义
// interface Album {
// id: unknown;
// title: unknown;
// artist: unknown;
// year: unknown;
// }
function assertIsAlbum(input: unknown): asserts input is Album {
if (
!(
typeof input === "object" &&
input !== null &&
"id" in input &&
"title" in input &&
"artist" in input &&
"year" in input
) // 注意:原始逻辑是如果条件为真则 return,这里是如果条件为假则 throw
) {
throw new Error("Not an Album!");
}
}
assertIsAlbum 函数接收一个类型为 unknown 的 input,并使用 asserts input is Album 语法断言它是一个 Album。
这意味着类型收窄更具侵略性。函数调用本身就足以断言 input 是一个 Album,而无需在 if 语句中进行检查。
typescript
// 假设 Album 类型定义了 title 属性
// interface Album { title: string; ... }
function getAlbumTitle(item: unknown) {
console.log(item);
// (parameter) item: unknown
assertIsAlbum(item);
console.log(item.title);
// (parameter) item: Album
}
当您希望在继续进一步操作之前确保某个值属于特定类型时,断言函数非常有用。
断言函数可能说谎
就像类型谓词一样,断言函数也可能被滥用。如果断言函数不能准确反映正在检查的类型,可能会导致运行时错误。
例如,如果 assertIsAlbum 函数没有检查 Album 的所有必需属性,可能会导致意外行为:
typescript
// 假设 Album 类型已定义
// interface Album { title: string; ... }
function assertIsAlbum(input: unknown): asserts input is Album {
// 错误的检查:只检查了是否为对象,没有检查具体属性
if (!(typeof input === "object" && input !== null)) { // 修正:如果不是对象或为null,则抛错
throw new Error("Not an Album!");
}
}
let item: any = null; // 使用 any 来模拟更危险的情况,或保持 unknown
assertIsAlbum(item); // 这里会因为 item 是 null 而抛出错误,如果检查不当则不会
// 如果上面的 assertIsAlbum 实现如下(原示例逻辑反了):
// function assertIsAlbum(input: unknown): asserts input is Album {
// if (typeof input === "object") { // 这是一个错误的断言逻辑
// throw new Error("Not an Album!"); // 应该是 !(条件)才 throw
// }
// }
// let item = null;
// assertIsAlbum(item); // 这个错误的断言不会抛错,因为 typeof null === 'object' 为真
// item.title; // 运行时错误:Cannot read properties of null (reading 'title')
// 正确的示例逻辑,但断言内容不完整
function assertIsAlbum_incomplete(input: unknown): asserts input is Album {
if (typeof input !== "object" || input === null) { // 仅检查是否为非 null 对象
throw new Error("Input is not a non-null object!");
}
// 没有检查 Album 的具体属性,如 title
}
let item_example = {} as any; // 一个空对象,它不是一个完整的 Album
assertIsAlbum_incomplete(item_example);
// item_example.title; // 运行时错误,因为 title 不存在,但类型系统认为 item_example 是 Album
// ^?
在这种情况下,assertIsAlbum 函数没有检查 Album 的必需属性------它只是检查 typeof input 是否为 "object"。这意味着我们让自己面临一个游离的 null。著名的 JavaScript 怪癖,即 typeof null === 'object',将导致我们在尝试访问 title 属性时发生运行时错误。
函数重载
函数重载为单个函数实现定义多种函数签名提供了一种方式。换句话说,你可以定义调用函数的不同方式,每种方式都有其自己的参数集和返回类型。这是一种创建灵活 API 的有趣技术,可以处理不同的用例,同时保持类型安全。
为了演示函数重载的工作原理,我们将创建一个 searchMusic 函数,该函数允许根据提供的参数以不同方式执行搜索。
定义重载
要定义函数重载,需要多次编写具有不同参数和返回类型的相同函数定义。每个定义称为一个重载签名,并用分号分隔。你还需要每次都使用 function 关键字。
对于 searchMusic 示例,我们希望允许用户通过提供艺术家、流派和年份进行搜索。但由于历史原因,我们希望他们能够将它们作为单个对象或作为单独的参数传递。
以下是我们如何定义这些函数重载签名。第一个签名接收三个独立的参数,而第二个签名接收一个具有这些属性的单个对象:
typescript
function searchMusic(artist: string, genre: string, year: number): void;
function searchMusic(criteria: {
artist: string;
genre: string;
year: number;
}): void;
// 函数实现缺失或未紧随声明之后。2391
但是我们收到了一个错误。我们已经声明了这个函数应该被声明的一些方式,但我们还没有提供实现。
实现签名
实现签名是包含函数实际逻辑的实际函数声明。它写在重载签名下方,并且必须与所有定义的重载兼容。
在这种情况下,实现签名将接收一个名为 artistOrCriteria 的参数,它可以是一个 string(代表艺术家)或一个具有指定属性的对象。在函数内部,我们将检查 artistOrCriteria 的类型,并根据提供的参数执行适当的搜索逻辑:
typescript
// 假设有一个名为 search 的函数用于实际搜索
declare function search(artist: string, genre?: string, year?: number): void;
declare function search(artist: string, genre: string, year: number): void;
function searchMusic(artist: string, genre: string, year: number): void;
function searchMusic(criteria: {
artist: string;
genre: string;
year: number;
}): void;
function searchMusic(
artistOrCriteria: string | { artist: string; genre: string; year: number },
genre?: string,
year?: number,
): void {
if (typeof artistOrCriteria === "string") {
// 使用独立参数搜索
search(artistOrCriteria, genre!, year!); // genre 和 year 在此分支下是必需的
} else {
// 使用对象搜索
search(
artistOrCriteria.artist,
artistOrCriteria.genre,
artistOrCriteria.year,
);
}
}
现在我们可以使用重载中定义的不同参数调用 searchMusic 函数:
typescript
searchMusic("King Gizzard and the Lizard Wizard", "Psychedelic Rock", 2021);
searchMusic({
artist: "Tame Impala",
genre: "Psychedelic Rock",
year: 2015,
});
然而,如果我们尝试传入与任何已定义重载都不匹配的参数,TypeScript 会警告我们:
typescript
searchMusic(
// 没有重载接受 2 个参数,但存在接受 1 个或 3 个参数的重载。2575
{
artist: "Tame Impala",
genre: "Psychedelic Rock",
year: 2015,
},
"Psychedelic Rock",
);
此错误表明我们试图用两个参数调用 searchMusic,但重载只期望一个或三个参数。
函数重载 vs 联合类型
当你有多个调用签名分布在不同的参数集上时,函数重载会很有用。在上面的例子中,我们可以用一个参数或三个参数来调用函数。
当你参数数量相同但类型不同时,应该使用联合类型而不是函数重载。例如,如果你想允许用户按艺术家名称或条件对象进行搜索,你可以使用联合类型:
typescript
// 假设有 searchByArtist 和 search 函数
declare function searchByArtist(query: string): void;
// declare function search(artist: string, genre: string, year: number): void; // 已在上面声明
function searchMusic(
query: string | { artist: string; genre: string; year: number },
): void {
if (typeof query === "string") {
// 按艺术家搜索
searchByArtist(query);
} else {
// 按所有条件搜索
search(query.artist, query.genre, query.year);
}
}
这比定义两个重载和一个实现使用的代码行数少得多。
练习
练习 1:使函数泛型化
这里我们有一个函数 createStringMap。这个函数的目的是生成一个 Map,其键为字符串,值为作为参数传入的类型:
typescript
const createStringMap = () => {
return new Map();
};
目前,我们得到的是 Map<any, any>。然而,目标是使这个函数泛型化,以便我们可以传入一个类型参数来定义 Map 中值的类型。
例如,如果我们传入 number 作为类型参数,函数应该返回一个值为 number 类型的 Map:
typescript
const numberMap = createStringMap<number>();
// 期望 0 个类型参数,但获得了 1 个。2558
numberMap.set("foo", 123);
同样,如果我们传入一个对象类型,函数应该返回一个值为该类型的 Map:
typescript
const objMap = createStringMap<{ a: number }>();
// 期望 0 个类型参数,但获得了 1 个。2558
objMap.set("foo", { a: 123 });
objMap.set(
"bar",
// @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578 // 实际上这里会因为类型不匹配而报错,所以 @ts-expect-error 是有用的
{ b: 123 },
);
如果未提供类型,函数也应默认为 unknown:
typescript
const unknownMap = createStringMap();
// 假设 Expect 和 Equal 已定义
// type Expect<T extends true> = T;
// type Equal<X, Y> =
// (<T>() => T extends X ? 1 : 2) extends
// (<T>() => T extends Y ? 1 : 2) ? true : false;
type test = Expect<Equal<typeof unknownMap, Map<string, unknown>>>;
// 类型 'false' 不满足约束 'true'。2344
你的任务是将 createStringMap 转换为一个能够接受类型参数以描述 Map 值的泛型函数。确保它按预期为提供的测试用例工作。
练习 2:默认类型参数
在练习 1 中使 createStringMap 函数泛型化后,不带类型参数调用它时,值默认为 unknown 类型:
typescript
const stringMap = createStringMap();
// 鼠标悬停在 stringMap 上显示:
// const stringMap: Map<string, unknown>;
你的目标是向 createStringMap 函数添加一个默认类型参数,以便在未提供类型参数时默认为 string。请注意,你仍然可以通过在调用函数时提供类型参数来覆盖默认类型。
练习 3:泛型函数中的推断
考虑这个 uniqueArray 函数:
typescript
const uniqueArray = (arr: any[]) => {
return Array.from(new Set(arr));
};
该函数接受一个数组作为参数,然后将其转换为 Set,再将其作为新数组返回。这是当您希望数组中具有唯一值时的常见模式。
虽然此函数在运行时有效,但它缺乏类型安全性。它将传入的任何数组转换为 any[]。
typescript
// 假设 it, expect, Expect, Equal 已定义
it("returns an array of unique values", () => {
const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
type test = Expect<Equal<typeof result, number[]>>;
// 类型 'false' 不满足约束 'true'。2344
expect(result).toEqual([1, 2, 3, 4, 5]);
});
it("should work on strings", () => {
const result = uniqueArray(["a", "b", "b", "c", "c", "c"]);
type test = Expect<Equal<typeof result, string[]>>;
// 类型 'false' 不满足约束 'true'。2344
expect(result).toEqual(["a", "b", "c"]);
});
你的任务是通过使 uniqueArray 函数泛型化来增强其类型安全性。
请注意,在测试中,我们在调用函数时没有显式提供类型参数。TypeScript 应该能够从参数中推断类型。
调整函数并插入必要的类型注释,以确保两个测试中的 result 类型分别被推断为 number[] 和 string[]。
练习 4:类型参数约束
考虑这个函数 addCodeToError,它接受一个类型参数 TError 并返回一个带有 code 属性的对象:
typescript
const UNKNOWN_CODE = 8000;
const addCodeToError = <TError>(error: TError) => {
return {
...error,
code: (error as any).code ?? UNKNOWN_CODE, // 临时用 as any 避免初始错误
// 属性 'code' 在类型 'TError' 上不存在。2339
};
};
如果传入的错误不包含 code,函数会分配一个默认的 UNKNOWN_CODE。目前 code 属性下有一个错误。
目前,对 TError 没有约束,它可以是任何类型。这导致了我们测试中的错误:
typescript
// 假设 it, console, Expect, Equal 已定义
it("Should accept a standard error", () => {
const errorWithCode = addCodeToError(new Error("Oh dear!"));
type test1 = Expect<Equal<typeof errorWithCode, Error & { code: number }>>;
// 类型 'false' 不满足约束 'true'。2344
console.log(errorWithCode.message);
type test2 = Expect<Equal<typeof errorWithCode.message, string>>; // 这个应该是 true
});
it("Should accept a custom error", () => {
const customErrorWithCode = addCodeToError({
message: "Oh no!",
code: 123,
filepath: "/",
});
type test3 = Expect<
Equal<
// 类型 'false' 不满足约束 'true'。2344
typeof customErrorWithCode,
{
message: string;
code: number;
filepath: string;
} & { // 实际上这里应该是 Omit<..., 'code'> & { code: number } 或者直接是 { ... , code: number }
code: number; // 这种写法等价于前面的 { message: string; code: number; filepath: string; }
}
>
>;
type test4 = Expect<Equal<typeof customErrorWithCode.message, string>>; // 这个应该是 true
});
你的任务是更新 addCodeToError 类型签名以强制执行所需的约束,以便 TError 必须具有 message 属性,并且可以可选地具有 code 属性。
练习 5:结合泛型类型和函数
这里我们有一个 safeFunction,它接受一个类型为 PromiseFunc 的函数 func,该函数本身返回一个函数。然而,如果 func 遇到错误,它会被捕获并返回:
typescript
type PromiseFunc = () => Promise<any>;
const safeFunction = (func: PromiseFunc) => async () => {
try {
const result = await func();
return result;
} catch (e) {
if (e instanceof Error) {
return e;
}
throw e;
}
};
简而言之,我们从 safeFunction 中得到的东西要么是 func 返回的东西,要么是一个 Error。
然而,当前的类型定义存在一些问题。
PromiseFunc 类型目前设置为始终返回 Promise<any>。这意味着 safeFunction 返回的函数应该返回 func 的结果或一个 Error,但目前它只返回 Promise<any>。
由于这些问题,有几个测试失败了:
typescript
// 假设 it, expect, Expect, Equal 已定义
it("should return an error if the function throws", async () => {
const func = safeFunction(async () => {
if (Math.random() > 0.5) {
throw new Error("Something went wrong");
}
return 123;
});
type test1 = Expect<Equal<typeof func, () => Promise<Error | number>>>;
// 类型 'false' 不满足约束 'true'。2344
const result = await func();
type test2 = Expect<Equal<typeof result, Error | number>>;
// 类型 'false' 不满足约束 'true'。2344
});
it("should return the result if the function succeeds", async () => {
const func = safeFunction(() => {
return Promise.resolve(`Hello!`);
});
type test1 = Expect<Equal<typeof func, () => Promise<string | Error>>>;
// 类型 'false' 不满足约束 'true'。2344
const result = await func();
type test2 = Expect<Equal<typeof result, string | Error>>;
// 类型 'false' 不满足约束 'true'。2344
expect(result).toEqual("Hello!");
});
你的任务是更新 safeFunction 使其具有泛型类型参数,并更新 PromiseFunc 使其不返回 Promise<any>。这将需要你结合泛型类型和函数,以确保测试成功通过。
练习 6:泛型函数中的多个类型参数
在练习 5 中使 safeFunction 泛型化后,它已更新为允许传递参数:
typescript
// 假设 PromiseFunc<TResult> 已定义为 (...args: any[]) => Promise<TResult>
// type PromiseFunc<TResult> = (...args: any[]) => Promise<TResult>;
const safeFunction =
<TResult>(func: PromiseFunc<TResult>) => // PromiseFunc 需要更新以接受 TArgs
async (...args: any[]) => {
// ^^^^^^^^^^^^^^ 现在可以接收参数了!
try {
const result = await func(...args);
return result;
} catch (e) {
if (e instanceof Error) {
return e;
}
throw e;
}
};
现在传递给 safeFunction 的函数可以接收参数,我们得到的返回函数也应该包含这些参数,并要求你传入它们。
然而,正如测试中所示,这并没有起作用:
typescript
// 假设 PromiseFunc 在这里是 (name: string) => Promise<string>
// 并且 safeFunction 的 TResult 被推断为 string
// 但返回的函数类型是 (...args: any[]) => Promise<string | Error>
it("should return the result if the function succeeds", async () => {
const func = safeFunction((name: string) => { // func 的类型是 (name: string) => Promise<string>
return Promise.resolve(`hello ${name}`);
});
type test1 = Expect<
Equal<typeof func, (name: string) => Promise<Error | string>>
// 类型 'false' 不满足约束 'true'。2344
>;
});
例如,在上面的测试中,name 没有被推断为 safeFunction 返回的函数的参数。相反,它实际上是说我们可以向函数中传递任意数量的参数,这是不正确的。
typescript
// 鼠标悬停在 func 上显示:
// const func: (...args: any[]) => Promise<string | Error>;
你的任务是向 PromiseFunc 和 safeFunction 添加第二个类型参数,以准确推断参数类型。
正如测试中所示,有些情况下不需要参数,而另一些情况下需要单个参数:
typescript
it("should return an error if the function throws", async () => {
const func = safeFunction(async () => {
if (Math.random() > 0.5) {
throw new Error("Something went wrong");
}
return 123;
});
type test1 = Expect<Equal<typeof func, () => Promise<Error | number>>>;
// 类型 'false' 不满足约束 'true'。2344
const result = await func();
type test2 = Expect<Equal<typeof result, Error | number>>;
});
it("should return the result if the function succeeds", async () => {
const func = safeFunction((name: string) => {
return Promise.resolve(`hello ${name}`);
});
type test1 = Expect<
Equal<typeof func, (name: string) => Promise<Error | string>>
// 类型 'false' 不满足约束 'true'。2344
>;
const result = await func("world");
type test2 = Expect<Equal<typeof result, string | Error>>;
expect(result).toEqual("hello world");
});
更新函数和泛型类型的类型,并使这些测试成功通过。
练习 7:断言函数
本练习从一个接口 User 开始,它具有 id 和 name 属性。然后我们有一个接口 AdminUser,它扩展了 User,继承了其所有属性并添加了一个 roles 字符串数组属性:
typescript
interface User {
id: string;
name: string;
}
interface AdminUser extends User {
roles: string[];
}
函数 assertIsAdminUser 接受 User 或 AdminUser 对象作为参数。如果参数中不存在 roles 属性,则函数抛出错误:
typescript
function assertIsAdminUser(user: User | AdminUser) {
if (!("roles" in user)) {
throw new Error("User is not an admin");
}
}
此函数的目的是验证我们能够访问特定于 AdminUser 的属性,例如 roles。
在 handleRequest 函数中,我们调用 assertIsAdminUser 并期望 user 的类型被收窄为 AdminUser。
但正如在此测试用例中看到的,它没有按预期工作:
typescript
const handleRequest = (user: User | AdminUser) => {
type test1 = Expect<Equal<typeof user, User | AdminUser>>; // 这个应该是 true
assertIsAdminUser(user);
type test2 = Expect<Equal<typeof user, AdminUser>>;
// 类型 'false' 不满足约束 'true'。2344
// user.roles; // Property 'roles' does not exist on type 'User | AdminUser'. Property 'roles' does not exist on type 'User'.2339
};
在调用 assertIsAdminUser 之前,user 类型是 User | AdminUser,但在调用函数后它没有被收窄为 AdminUser。这意味着我们无法访问 roles 属性。
你的任务是使用正确的类型断言更新 assertIsAdminUser 函数,以便在调用函数后将 user 标识为 AdminUser。
解决方案 1:使函数泛型化
我们要做的第一件事是向此函数添加一个类型参数 T:
typescript
const createStringMap = <T>() => {
return new Map();
};
通过此更改,我们的 createStringMap 函数现在可以处理类型参数 T。
numberMap 变量的错误消失了,但函数仍然返回 Map<any, any>:
typescript
const numberMap = createStringMap<number>();
// 鼠标悬停在 createStringMap 上显示:
// const createStringMap: <number>() => Map<any, any>;
我们需要为 map 条目指定类型。
因为我们知道键将始终是字符串,所以我们将 Map 的第一个类型参数设置为 string。对于值,我们将使用我们的类型参数 T:
typescript
const createStringMap = <T>() => {
return new Map<string, T>();
};
现在函数可以正确地类型化 map 的值。
如果我们不传入类型参数,函数将默认为 unknown:
typescript
const objMap = createStringMap();
// 鼠标悬停在 objMap 上显示:
// const objMap: Map<string, unknown>;
通过这些步骤,我们成功地将 createStringMap 从一个常规函数转换为一个能够接收类型参数的泛型函数。
解决方案 2:默认类型参数
为泛型函数设置默认类型的语法与泛型类型相同:
typescript
const createStringMap = <T = string>() => {
return new Map<string, T>();
};
通过使用 T = string 语法,我们告诉函数如果未提供类型参数,则应默认为 string。
现在当我们不带类型参数调用 createStringMap() 时,我们得到一个键和值都为 string 的 Map:
typescript
const stringMap = createStringMap();
// 鼠标悬停在 stringMap 上显示:
// const stringMap: Map<string, string>;
如果我们尝试将数字作为值赋给它,TypeScript 会报错,因为它期望一个字符串:
typescript
stringMap.set("bar", 123);
// 类型 'number' 的参数不能赋给类型 'string' 的参数。2345
然而,我们仍然可以通过在调用函数时提供类型参数来覆盖默认类型:
typescript
const numberMap = createStringMap<number>();
numberMap.set("foo", 123);
在上面的代码中,numberMap 将产生一个键为 string、值为 number 的 Map,如果我们尝试赋一个非数字值,TypeScript 会报错:
typescript
numberMap.set(
"bar",
// @ts-expect-error
true,
);
解决方案 3:泛型函数中的推断
第一步是向 uniqueArray 添加一个类型参数。这将 uniqueArray 转换为可以接收类型参数的泛型函数:
typescript
const uniqueArray = <T>(arr: any[]) => {
return Array.from(new Set(arr));
};
现在,当我们鼠标悬停在对 uniqueArray 的调用上时,我们可以看到它将类型推断为 unknown:
typescript
const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
// const uniqueArray: <unknown>(arr: any[]) => any[]
这是因为我们没有向它传递任何类型参数。如果没有类型参数且没有默认值,则默认为 unknown。
我们希望类型参数被推断为 number,因为我们知道我们得到的是一个数字数组。
所以我们将要做的是为函数添加一个 T[] 的返回类型:
typescript
const uniqueArray = <T>(arr: any[]): T[] => {
return Array.from(new Set(arr));
};
现在 uniqueArray 的结果被推断为一个 unknown 数组:
typescript
const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
// const result: unknown[]
同样,原因在于我们没有向它传递任何类型参数。如果没有类型参数且没有默认值,则默认为 unknown。
如果我们向调用添加一个 <number> 类型参数,result 现在将被推断为一个数字数组:
typescript
const result = uniqueArray<number>([1, 1, 2, 3, 4, 4, 5]);
// const result: number[]
然而,此时我们传入的内容和得到的内容之间没有关系。向调用添加类型参数会返回该类型的数组,但函数本身的 arr 参数仍然类型化为 any[]。
我们需要做的是告诉 TypeScript arr 参数的类型与传入的类型相同。
为此,我们将 arr: any[] 替换为 arr: T[]:
typescript
const uniqueArray = <T>(arr: T[]): T[] => {
// ...
return Array.from(new Set(arr)); // Set 也应该是 Set<T>
};
更正后的完整函数:
typescript
const uniqueArray = <T>(arr: T[]): T[] => {
return Array.from(new Set<T>(arr));
};
函数的返回类型是 T 的数组,其中 T 表示提供给函数的数组中元素的类型。
因此,即使没有显式的返回类型注释,TypeScript 也可以将数字输入数组的返回类型推断为 number[],或将字符串输入数组的返回类型推断为 string[]。正如我们所见,测试成功通过:
typescript
// 数字测试
const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
type test = Expect<Equal<typeof result, number[]>>;
// 字符串测试
const result_strings = uniqueArray(["a", "b", "b", "c", "c", "c"]); // 变量名修改以避免重复声明
type test_strings = Expect<Equal<typeof result_strings, string[]>>;
如果你显式传递类型参数,TypeScript 将使用它。如果你不传递,TypeScript 会尝试从运行时参数中推断它。
解决方案 4:类型参数约束
添加约束的语法与我们看到的泛型类型的语法相同。
我们需要使用 extends 关键字为泛型类型参数 TError 添加约束。传入的对象必须具有 string 类型的 message 属性,并且可以可选地具有 number 类型的 code 属性:
typescript
const UNKNOWN_CODE = 8000;
const addCodeToError = <TError extends { message: string; code?: number }>(
error: TError,
) => {
return {
...error,
code: error.code ?? UNKNOWN_CODE,
};
};
此更改确保 addCodeToError 必须使用包含 message 字符串属性的对象来调用。TypeScript 还知道 code 可以是数字或 undefined。如果 code 不存在,它将默认为 UNKNOWN_CODE。
这些约束使我们的测试通过,包括我们传入额外 filepath 属性的情况。这是因为在泛型中使用 extends 并不会限制你只能传入约束中定义的属性。
解决方案 5:结合泛型类型和函数
这是我们 safeFunction 的起点:
typescript
type PromiseFunc = () => Promise<any>;
const safeFunction = (func: PromiseFunc) => async () => {
try {
const result = await func();
return result;
} catch (e) {
if (e instanceof Error) {
return e;
}
throw e;
}
};
我们要做的第一件事是将 PromiseFunc 类型更新为泛型类型。我们将类型参数称为 TResult,以表示 promise 返回的值的类型,并将其添加到函数的返回类型中:
typescript
type PromiseFunc<TResult> = () => Promise<TResult>;
通过此更新,我们现在需要在 safeFunction 中更新 PromiseFunc 以包含类型参数:
typescript
const safeFunction =
<TResult>(func: PromiseFunc<TResult>) =>
async (): Promise<TResult | Error> => { // 添加返回类型注解
try {
const result = await func();
return result;
} catch (e) {
if (e instanceof Error) {
return e;
}
throw e;
}
};
完成这些更改后,当我们将鼠标悬停在第一个测试中的 safeFunction 调用上时,我们可以看到类型参数按预期推断为 number:
typescript
it("should return an error if the function throws", async () => {
const func = safeFunction(async () => {
if (Math.random() > 0.5) {
throw new Error("Something went wrong");
}
return 123;
});
// ...
});
// 鼠标悬停在 safeFunction 上显示:
// const safeFunction: <number>(func: PromiseFunc<number>) => () => Promise<number | Error>
其他测试也通过了。
无论我们将什么传递给 safeFunction,都将被推断为 PromiseFunc 的类型参数。这是因为类型参数是在泛型函数内部被推断的。
这种泛型函数和泛型类型的组合可以使你的泛型函数更易于阅读。
解决方案 6:泛型函数中的多个类型参数
PromiseFunc 目前的定义方式如下(根据上一个练习的推断):
typescript
// type PromiseFunc<TResult> = () => Promise<TResult>;
// 在这个练习中,它被修改为接受参数:
// type PromiseFunc<TResult> = (...args: any[]) => Promise<TResult>;
首先要做的是弄清楚传入参数的类型。目前,它们被设置为一个值,但它们需要根据传入的函数类型而有所不同。
我们不希望 args 的类型是 any[],而是希望展开所有 args 并捕获整个数组。
为此,我们将类型更新为 TArgs。由于 args 需要是一个数组,我们将声明 TArgs extends any[]。请注意,这并不意味着 TArgs 的类型将是 any,而是它将接受任何类型的数组:
typescript
type PromiseFunc<TArgs extends any[], TResult> = (
...args: TArgs
) => Promise<TResult>;
你可能尝试过用 unknown[] ------ 但在这种情况下,any[] 是唯一有效的方法。(译者注:unknown[] 也可以工作,但 ...args: TArgs 这种 rest参数的类型 TArgs 通常用 extends any[] 或 extends unknown[] 来约束,any[] 更常见于允许任何操作的场景,而 unknown[] 更类型安全,但在某些推断场景下 any[] 可能更灵活或符合某些库的习惯。)
现在我们需要更新 safeFunction,使其具有与 PromiseFunc 相同的参数。为此,我们将向其类型参数添加 TArgs。
请注意,我们还需要将 async 函数的 args 更新为 TArgs 类型:
typescript
const safeFunction =
<TArgs extends any[], TResult>(func: PromiseFunc<TArgs, TResult>) =>
async (...args: TArgs): Promise<TResult | Error> => { // 添加返回类型注解
try {
const result = await func(...args);
return result;
} catch (e) {
if (e instanceof Error) {
return e;
}
throw e; // 重新抛出未捕获的错误
}
};
为了确保 safeFunction 返回的函数具有与原始函数相同的类型化参数,此更改是必需的。
通过这些更改,我们所有的测试都按预期通过。
解决方案 7:断言函数
解决方案是在 assertIsAdminUser 的返回类型上添加类型注解。
如果它是一个类型谓词,我们会说 user is AdminUser:
typescript
// function assertIsAdminUser(user: User): user is AdminUser { // 这里应该是 User | AdminUser
// 函数的声明类型既不是 'undefined'、'void',也不是 'any',则必须返回值。2355
// if (!("roles" in user)) {
// throw new Error("User is not an admin");
// }
// }
然而,这会导致错误。我们得到这个错误是因为 assertIsAdminUser 返回 void,这与要求返回布尔值的类型谓词不同。
相反,我们需要向返回类型添加 asserts 关键字:
typescript
function assertIsAdminUser(user: User | AdminUser): asserts user is AdminUser {
if (!("roles" in user)) {
throw new Error("User is not an admin");
}
}
通过添加 asserts 关键字,仅仅因为调用了 assertIsAdminUser,我们就可以断言用户是 AdminUser。我们不需要将其放在 if 语句或其他任何地方。
asserts 更改到位后,在调用 assertIsAdminUser 后,user 类型被收窄为 AdminUser,并且测试按预期通过:
typescript
const handleRequest = (user: User | AdminUser) => {
type test1 = Expect<Equal<typeof user, User | AdminUser>>;
assertIsAdminUser(user);
type test2 = Expect<Equal<typeof user, AdminUser>>;
user.roles;
};
// 鼠标悬停在 roles 上显示:
// user: AdminUser;