TypeScript 工具函数开发

7 阅读1分钟

TypeScript 工具函数开发

通过实践练习学习 TypeScript 泛型函数、类型参数、谓词和函数重载。复杂类型操作和调试。

通常认为 TypeScript 有两个复杂性级别。

一方面,你进行库的开发。在这里,你会利用 TypeScript 中许多最晦涩但强大的特性。你需要条件类型、映射类型、泛型等等,来创建一个足够灵活以便在各种场景中使用的库。

另一方面,你进行应用程序开发。在这里,你主要关心的是确保代码类型安全。你希望确保类型能够反映应用程序中正在发生的事情。任何复杂类型都封装在你使用的库中。你需要了解 TypeScript 的使用方式,但不太需要使用其高级特性。

这是大多数 TypeScript 社区成员使用的经验法则。“对于应用程序代码来说太复杂了”。“你只会在库中需要它”。但是,还有一个经常被忽视的第三个级别:/utils 文件夹。

如果你的应用程序变得足够大,你会开始将常见的模式提取到一组可重用的函数中。这些函数,如 groupBydebounceretry,可能在大型应用程序中被使用数百次。它们就像应用程序范围内的迷你库。

理解如何构建这些类型的函数可以为你的团队节省大量时间。捕获常见模式意味着你的代码变得更易于维护,构建速度也更快。

在本章中,我们将介绍如何构建这些函数。我们将从泛型函数开始,然后介绍类型谓词、断言函数和函数重载。

泛型函数

我们已经看到,在 TypeScript 中,函数不仅可以接收值作为参数,还可以接收类型。在这里,我们向 new Set() 传递了一个和一个类型

const set = new Set<number>([1, 2, 3]);

//                 ^^^^^^^^ ^^^^^^^^^

//                 类型     值

我们在尖括号中传递类型,在圆括号中传递值。这是因为 new Set() 是一个泛型函数。不能接收类型的函数是常规函数,比如 JSON.parse

const obj = JSON.parse<{ hello: string }>('{"hello": "world"}');
// 类型参数应为 0 个,但获得了 1 个。2558

在这里,TypeScript 告诉我们 JSON.parse 不接受类型参数,因为它不是泛型的。

是什么让函数成为泛型?

如果函数声明了一个类型参数,那么它就是泛型的。这是一个带有类型参数 T 的泛型函数:

function identity<T>(arg: T): T {

  //                 ^^^ 类型参数

  return arg;

}

我们可以使用 function 关键字,或者使用箭头函数语法:

const identity = <T>(arg: T): T => arg;

我们甚至可以将泛型函数声明为一个类型:

type Identity = <T>(arg: T) => T;

const identity: Identity = (arg) => arg;

现在,我们可以向 identity 传递一个类型参数:

identity<number>(42);
泛型函数类型别名 vs 泛型类型

非常重要的一点是,不要将泛型类型的语法与泛型函数的类型别名的语法混淆。对于未经训练的人来说,它们看起来非常相似。以下是区别:

// 泛型函数的类型别名

type Identity = <T>(arg: T) => T;

//              ^^^

//              类型参数属于函数

// 泛型类型

type Identity<T> = (arg: T) => T;

//           ^^^

//           类型参数属于类型

关键在于类型参数的位置。如果它附加在类型名称上,它就是一个泛型类型。如果它附加在函数的圆括号上,它就是一个泛型函数的类型别名。

当我们不传入类型参数时会发生什么?

当我们研究泛型类型时,我们看到 TypeScript 要求你在使用泛型类型时传入所有类型参数:

type StringArray = Array<string>;

type AnyArray = Array;
// 泛型类型 'Array<T>' 需要 1 个类型参数。2314

这对于泛型函数来说并非如此。如果你不向泛型函数传递类型参数,TypeScript 不会报错:

function identity<T>(arg: T): T {

  return arg;

}

const result = identity(42); // 没有错误!

这是为什么呢?嗯,这是泛型函数的一个特性,也使它们成为我最喜欢的 TypeScript 工具。如果你不传递类型参数,TypeScript 将尝试从函数的运行时参数中推断它。

我们上面的 identity 函数只是接收一个参数并返回它。我们已经在运行时参数中引用了类型参数:arg: T。这意味着如果我们不传入类型参数,T 将从 arg 的类型中推断出来。

所以,result 的类型将是 42

const result = identity(42);
//      const result: 42

这意味着每次调用该函数时,它都可能返回不同的类型:

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 函数试试:

const result = identity<string>(42);
// 类型 'number' 的参数不能赋给类型 'string' 的参数。2345

在这里,TypeScript 告诉我们 42 不是一个 string。这是因为我们明确告诉 TypeScript T 应该是一个 string,这与运行时参数冲突。

传递类型参数是给 TypeScript 的一个指令,用于覆盖推断。如果你传入一个类型参数,TypeScript 会将其用作事实的来源。如果你不传入,TypeScript 会将运行时参数的类型用作事实的来源。

没有所谓的“一个泛型”

这里快速说明一下术语。TypeScript 的“泛型”以难以理解著称。我认为很大程度上是因为人们使用“泛型”这个词的方式。

很多人认为“泛型”是 TypeScript 的一部分。他们把它看作一个名词。如果你问别人“这段代码中的‘泛型’在哪里?”:

const identity = <T>(arg: T) => arg;

他们可能会指向 <T>。其他人可能会将下面的代码描述为“向 Set 传递一个‘泛型’”:

const set = new Set<number>([1, 2, 3]);

这种术语会变得非常混乱。相反,我更喜欢将它们分成不同的术语:

  • 类型参数 (Type Parameter):identity<T> 中的 <T>
  • 类型实参 (Type Argument):传递给 Set<number>number
  • 泛型类/函数/类型 (Generic Class/Function/Type):声明了类型参数的类、函数或类型。

当你把泛型分解成这些术语时,理解起来就容易多了。

泛型函数解决的问题

让我们把学到的知识付诸实践。

考虑这个名为 getFirstElement 的函数,它接受一个数组并返回第一个元素:

const getFirstElement = (arr: any[]) => {

  return arr[0];

};

这个函数很危险。因为它接受一个 any 类型的数组,这意味着我们从 getFirstElement 中得到的东西也是 any

const first = getFirstElement([1, 2, 3]);
//     const first: any

正如我们所见,any 可能会在你的代码中造成严重破坏。任何使用此函数的人都会在不知不觉中放弃 TypeScript 的类型安全。那么,我们该如何修复它呢?

我们需要 TypeScript 理解我们传入的数组的类型,并用它来类型化返回的内容。我们需要使 getFirstElement 成为泛型:

为此,我们将在函数的参数列表前添加一个类型参数 TMember,然后使用 TMember[] 作为数组的类型:

const getFirstElement = <TMember>(arr: TMember[]) => {

  return arr[0];

};

就像泛型函数一样,通常用 T 作为类型参数的前缀,以区别于普通类型。

现在当我们调用 getFirstElement 时,TypeScript 会根据我们传入的参数推断出 TMember 的类型:

const firstNumber = getFirstElement([1, 2, 3]);
//        const firstNumber: number
const firstString = getFirstElement(["a", "b", "c"]);
//        const firstString: string

现在,我们已经使 getFirstElement 类型安全了。我们传入的数组的类型就是我们得到的元素的类型。

调试泛型函数的推断类型

当你使用泛型函数时,可能很难知道 TypeScript 推断出了什么类型。然而,通过仔细地将鼠标悬停,你可以找到答案。

当我们调用 getFirstElement 函数时,我们可以将鼠标悬停在函数名上,看看 TypeScript 推断出了什么:

const first = getFirstElement([1, 2, 3]);
//                  const getFirstElement: <number>(arr: number[]) => number

我们可以看到,在尖括号内,TypeScript 推断出 TMembernumber,因为我们传入了一个数字数组。

当你有更复杂的函数和多个类型参数需要调试时,这会很有用。我经常发现自己会在同一个文件中创建临时的函数调用,以查看 TypeScript 推断出了什么。

类型参数默认值

就像泛型类型一样,你可以在泛型函数中为类型参数设置默认值。当函数的运行时参数是可选的时,这会很有用:

const createSet = <T = string>(arr?: T[]) => {

  return new Set(arr);

};

这里,我们将 T 的默认类型设置为 string。这意味着如果我们不传入类型参数,TypeScript 会假定 Tstring

const defaultSet = createSet();
//        const defaultSet: Set<string>

默认值不会对 T 的类型施加约束。这意味着我们仍然可以传入任何我们想要的类型:

const numberSet = createSet<number>([1, 2, 3]);
//       const numberSet: Set<number>

如果我们不指定默认值,并且 TypeScript 无法从运行时参数中推断类型,它将默认为 unknown

const createSet = <T>(arr?: T[]) => {

  return new Set(arr);

};

const unknownSet = createSet();
//        const unknownSet: Set<unknown>

这里,我们移除了 T 的默认类型,TypeScript 默认其为 unknown

约束类型参数

你还可以为泛型函数中的类型参数添加约束。当你希望确保一个类型具有某些属性时,这会很有用。

让我们想象一个 removeId 函数,它接受一个对象并移除 id 属性:

const removeId = <TObj>(obj: TObj) => {

  const { id, ...rest } = obj;
// 属性 'id' 在类型 'unknown' 上不存在。2339
  return rest;

};

我们的 TObj 类型参数,在没有约束的情况下使用时,被视为 unknown。这意味着 TypeScript 不知道 id 是否存在于 obj 上。

要解决这个问题,我们可以为 TObj 添加一个约束,确保它具有 id 属性:

const removeId = <TObj extends { id: unknown }>(obj: TObj) => {

  const { id, ...rest } = obj;

  return rest;

};

现在,当我们使用 removeId 时,如果我们传入的对象没有 id 属性,TypeScript 会报错:

const result = removeId({ name: "Alice" });
// 对象字面量只能指定已知的属性,而 'name' 在类型 '{ id: unknown; }' 中不存在。2353

但是如果我们传入一个带有 id 属性的对象,TypeScript 会知道 id 已经被移除了:

const result = removeId({ id: 1, name: "Alice" });
//      const result: Omit<{
//    id: number;
//    name: string;
// }, "id">

注意 TypeScript 在这里有多聪明。尽管我们没有为 removeId 指定返回类型,TypeScript 还是推断出 result 是一个对象,它拥有输入对象的所有属性,除了 id

类型谓词

早在第 5 章,当我们学习类型收窄时,就已经接触过类型谓词了。它们用于捕获可重用的逻辑,以收窄变量的类型。

例如,假设我们想在尝试访问某个变量的属性或将其传递给需要 Album 类型的函数之前,确保该变量是一个 Album

我们可以编写一个 isAlbum 函数,它接收一个输入,并检查所有必需的属性。

function isAlbum(input: unknown) {
  return (
    typeof input === "object" &&
    input !== null &&
    "id" in input &&
    "title" in input &&
    "artist" in input &&
    "year" in input
  );
}

如果我们将鼠标悬停在 isAlbum 上,我们可以看到一个相当丑陋的类型签名:

// 鼠标悬停在 isAlbum 上显示:
function isAlbum(
  input: unknown,
): input is object &
  Record<"id", unknown> &
  Record<"title", unknown> &
  Record<"artist", unknown> &
  Record<"year", unknown>;

这在技术上是正确的:一个 object 和一堆 Record 之间的大型交叉类型。但这并没有太大帮助。

当我们尝试使用 isAlbum 来收窄一个值的类型时,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 添加更多的检查,以确保我们正在检查所有属性的类型:

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"
  );
}

这可能感觉过于冗长。我们可以通过添加我们自己的类型谓词来使其更具可读性。

// 假设 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

const run = (maybeAlbum: unknown) => {
  if (isAlbum(maybeAlbum)) {
    // 假设 Album 有 title 属性,并且 title 是我们要操作的
    // 如果是 name 属性,isAlbum 和 Album 定义需要对应
    (maybeAlbum as Album).title.toUpperCase(); // 没有错误!
    // 或者如果 isAlbum 内部已经充分检查了类型
    // maybeAlbum.title.toUpperCase();
  }
};

对于复杂的类型守卫,这可能更具可读性。

类型谓词可能不安全

编写自己的类型谓词可能有点危险。如果类型谓词不能准确反映正在检查的类型,TypeScript 不会捕获这种差异:

// 假设 Album 类型已定义
// interface Album { id: number; title: string; ... }

function isAlbum(input: any): input is Album {
  return typeof input === "object";
}

在这种情况下,传递给 isAlbum 的任何对象都将被视为 Album,即使它没有必需的属性。这是使用类型谓词时常见的陷阱——重要的是要将它们视为与 as! 一样不安全。

断言函数

断言函数看起来与类型谓词相似,但它们的用法略有不同。断言函数不是返回一个布尔值来指示值是否属于某个类型,而是在值不符合预期类型时抛出错误。

以下是我们如何将 isAlbum 类型谓词重构为 assertIsAlbum 断言函数:

// 假设 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 函数接收一个类型为 unknowninput,并使用 asserts input is Album 语法断言它是一个 Album

这意味着类型收窄更具侵略性。函数调用本身就足以断言 input 是一个 Album,而无需在 if 语句中进行检查。

// 假设 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 的所有必需属性,可能会导致意外行为:

// 假设 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 示例,我们希望允许用户通过提供艺术家、流派和年份进行搜索。但由于历史原因,我们希望他们能够将它们作为单个对象或作为单独的参数传递。

以下是我们如何定义这些函数重载签名。第一个签名接收三个独立的参数,而第二个签名接收一个具有这些属性的单个对象:

function searchMusic(artist: string, genre: string, year: number): void;
function searchMusic(criteria: {
  artist: string;
  genre: string;
  year: number;
}): void;
// 函数实现缺失或未紧随声明之后。2391

但是我们收到了一个错误。我们已经声明了这个函数应该被声明的一些方式,但我们还没有提供实现。

实现签名

实现签名是包含函数实际逻辑的实际函数声明。它写在重载签名下方,并且必须与所有定义的重载兼容。

在这种情况下,实现签名将接收一个名为 artistOrCriteria 的参数,它可以是一个 string(代表艺术家)或一个具有指定属性的对象。在函数内部,我们将检查 artistOrCriteria 的类型,并根据提供的参数执行适当的搜索逻辑:

// 假设有一个名为 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 函数:

searchMusic("King Gizzard and the Lizard Wizard", "Psychedelic Rock", 2021);

searchMusic({
  artist: "Tame Impala",
  genre: "Psychedelic Rock",
  year: 2015,
});

然而,如果我们尝试传入与任何已定义重载都不匹配的参数,TypeScript 会警告我们:

searchMusic(
// 没有重载接受 2 个参数,但存在接受 1 个或 3 个参数的重载。2575
  {
    artist: "Tame Impala",
    genre: "Psychedelic Rock",
    year: 2015,
  },
  "Psychedelic Rock",
);

此错误表明我们试图用两个参数调用 searchMusic,但重载只期望一个或三个参数。

函数重载 vs 联合类型

当你有多个调用签名分布在不同的参数集上时,函数重载会很有用。在上面的例子中,我们可以用一个参数或三个参数来调用函数。

当你参数数量相同但类型不同时,应该使用联合类型而不是函数重载。例如,如果你想允许用户按艺术家名称或条件对象进行搜索,你可以使用联合类型:

// 假设有 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,其键为字符串,值为作为参数传入的类型:

const createStringMap = () => {
  return new Map();
};

目前,我们得到的是 Map<any, any>。然而,目标是使这个函数泛型化,以便我们可以传入一个类型参数来定义 Map 中值的类型。

例如,如果我们传入 number 作为类型参数,函数应该返回一个值为 number 类型的 Map

const numberMap = createStringMap<number>();
// 期望 0 个类型参数,但获得了 1 个。2558

numberMap.set("foo", 123);

同样,如果我们传入一个对象类型,函数应该返回一个值为该类型的 Map

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

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 类型:

const stringMap = createStringMap();

// 鼠标悬停在 stringMap 上显示:
// const stringMap: Map<string, unknown>;

你的目标是向 createStringMap 函数添加一个默认类型参数,以便在未提供类型参数时默认为 string。请注意,你仍然可以通过在调用函数时提供类型参数来覆盖默认类型。

练习 3:泛型函数中的推断

考虑这个 uniqueArray 函数:

const uniqueArray = (arr: any[]) => {
  return Array.from(new Set(arr));
};

该函数接受一个数组作为参数,然后将其转换为 Set,再将其作为新数组返回。这是当您希望数组中具有唯一值时的常见模式。

虽然此函数在运行时有效,但它缺乏类型安全性。它将传入的任何数组转换为 any[]

// 假设 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 属性的对象:

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 没有约束,它可以是任何类型。这导致了我们测试中的错误:

// 假设 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 遇到错误,它会被捕获并返回:

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>

由于这些问题,有几个测试失败了:

// 假设 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 泛型化后,它已更新为允许传递参数:

// 假设 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 的函数可以接收参数,我们得到的返回函数应该包含这些参数,并要求你传入它们。

然而,正如测试中所示,这并没有起作用:

// 假设 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 返回的函数的参数。相反,它实际上是说我们可以向函数中传递任意数量的参数,这是不正确的。

// 鼠标悬停在 func 上显示:
// const func: (...args: any[]) => Promise<string | Error>;

你的任务是向 PromiseFuncsafeFunction 添加第二个类型参数,以准确推断参数类型。

正如测试中所示,有些情况下不需要参数,而另一些情况下需要单个参数:

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 开始,它具有 idname 属性。然后我们有一个接口 AdminUser,它扩展了 User,继承了其所有属性并添加了一个 roles 字符串数组属性:

interface User {
  id: string;
  name: string;
}

interface AdminUser extends User {
  roles: string[];
}

函数 assertIsAdminUser 接受 UserAdminUser 对象作为参数。如果参数中不存在 roles 属性,则函数抛出错误:

function assertIsAdminUser(user: User | AdminUser) {
  if (!("roles" in user)) {
    throw new Error("User is not an admin");
  }
}

此函数的目的是验证我们能够访问特定于 AdminUser 的属性,例如 roles

handleRequest 函数中,我们调用 assertIsAdminUser 并期望 user 的类型被收窄为 AdminUser

但正如在此测试用例中看到的,它没有按预期工作:

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

const createStringMap = <T>() => {
  return new Map();
};

通过此更改,我们的 createStringMap 函数现在可以处理类型参数 T

numberMap 变量的错误消失了,但函数仍然返回 Map<any, any>

const numberMap = createStringMap<number>();

// 鼠标悬停在 createStringMap 上显示:
// const createStringMap: <number>() => Map<any, any>;

我们需要为 map 条目指定类型。

因为我们知道键将始终是字符串,所以我们将 Map 的第一个类型参数设置为 string。对于值,我们将使用我们的类型参数 T

const createStringMap = <T>() => {
  return new Map<string, T>();
};

现在函数可以正确地类型化 map 的值。

如果我们不传入类型参数,函数将默认为 unknown

const objMap = createStringMap();

// 鼠标悬停在 objMap 上显示:
// const objMap: Map<string, unknown>;

通过这些步骤,我们成功地将 createStringMap 从一个常规函数转换为一个能够接收类型参数的泛型函数。

解决方案 2:默认类型参数

为泛型函数设置默认类型的语法与泛型类型相同:

const createStringMap = <T = string>() => {
  return new Map<string, T>();
};

通过使用 T = string 语法,我们告诉函数如果未提供类型参数,则应默认为 string

现在当我们不带类型参数调用 createStringMap() 时,我们得到一个键和值都为 stringMap

const stringMap = createStringMap();

// 鼠标悬停在 stringMap 上显示:
// const stringMap: Map<string, string>;

如果我们尝试将数字作为值赋给它,TypeScript 会报错,因为它期望一个字符串:

stringMap.set("bar", 123);
// 类型 'number' 的参数不能赋给类型 'string' 的参数。2345

然而,我们仍然可以通过在调用函数时提供类型参数来覆盖默认类型:

const numberMap = createStringMap<number>();

numberMap.set("foo", 123);

在上面的代码中,numberMap 将产生一个键为 string、值为 numberMap,如果我们尝试赋一个非数字值,TypeScript 会报错:

numberMap.set(
  "bar",
  // @ts-expect-error
  true,
);

解决方案 3:泛型函数中的推断

第一步是向 uniqueArray 添加一个类型参数。这将 uniqueArray 转换为可以接收类型参数的泛型函数:

const uniqueArray = <T>(arr: any[]) => {
  return Array.from(new Set(arr));
};

现在,当我们鼠标悬停在对 uniqueArray 的调用上时,我们可以看到它将类型推断为 unknown

const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
//                 const uniqueArray: <unknown>(arr: any[]) => any[]

这是因为我们没有向它传递任何类型参数。如果没有类型参数且没有默认值,则默认为 unknown。

我们希望类型参数被推断为 number,因为我们知道我们得到的是一个数字数组。

所以我们将要做的是为函数添加一个 T[] 的返回类型:

const uniqueArray = <T>(arr: any[]): T[] => {
  return Array.from(new Set(arr));
};

现在 uniqueArray 的结果被推断为一个 unknown 数组:

const result = uniqueArray([1, 1, 2, 3, 4, 4, 5]);
//      const result: unknown[]

同样,原因在于我们没有向它传递任何类型参数。如果没有类型参数且没有默认值,则默认为 unknown。

如果我们向调用添加一个 <number> 类型参数,result 现在将被推断为一个数字数组:

const result = uniqueArray<number>([1, 1, 2, 3, 4, 4, 5]);
//      const result: number[]

然而,此时我们传入的内容和得到的内容之间没有关系。向调用添加类型参数会返回该类型的数组,但函数本身的 arr 参数仍然类型化为 any[]

我们需要做的是告诉 TypeScript arr 参数的类型与传入的类型相同。

为此,我们将 arr: any[] 替换为 arr: T[]

const uniqueArray = <T>(arr: T[]): T[] => {
  // ...
  return Array.from(new Set(arr)); // Set 也应该是 Set<T>
};

更正后的完整函数:

const uniqueArray = <T>(arr: T[]): T[] => {
  return Array.from(new Set<T>(arr));
};

函数的返回类型是 T 的数组,其中 T 表示提供给函数的数组中元素的类型。

因此,即使没有显式的返回类型注释,TypeScript 也可以将数字输入数组的返回类型推断为 number[],或将字符串输入数组的返回类型推断为 string[]。正如我们所见,测试成功通过:

// 数字测试
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 属性:

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 的起点:

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 返回的值的类型,并将其添加到函数的返回类型中:

type PromiseFunc<TResult> = () => Promise<TResult>;

通过此更新,我们现在需要在 safeFunction 中更新 PromiseFunc 以包含类型参数:

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

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 目前的定义方式如下(根据上一个练习的推断):

// type PromiseFunc<TResult> = () => Promise<TResult>;
// 在这个练习中,它被修改为接受参数:
// type PromiseFunc<TResult> = (...args: any[]) => Promise<TResult>;

首先要做的是弄清楚传入参数的类型。目前,它们被设置为一个值,但它们需要根据传入的函数类型而有所不同。

我们不希望 args 的类型是 any[],而是希望展开所有 args 并捕获整个数组。

为此,我们将类型更新为 TArgs。由于 args 需要是一个数组,我们将声明 TArgs extends any[]。请注意,这并不意味着 TArgs 的类型将是 any,而是它将接受任何类型的数组:

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 类型:

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

// 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 关键字:

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,并且测试按预期通过:

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;