TypeScript 工具函数开发

TypeScript 工具函数开发

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

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

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

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

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

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

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

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

泛型函数

我们已经看到,在 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 推断出 TMembernumber,因为我们传入了一个数字数组。

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

类型参数默认值

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

typescript 复制代码
const createSet = <T = string>(arr?: T[]) => {

  return new Set(arr);

};

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

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 函数接收一个类型为 unknowninput,并使用 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>;

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

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

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

typescript 复制代码
interface User {
  id: string;
  name: string;
}

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

函数 assertIsAdminUser 接受 UserAdminUser 对象作为参数。如果参数中不存在 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() 时,我们得到一个键和值都为 stringMap

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、值为 numberMap,如果我们尝试赋一个非数字值,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;
相关推荐
jonjia2 小时前
注解与断言
typescript
jonjia2 小时前
IDE 超能力
typescript
jonjia2 小时前
对象类型
typescript
jonjia2 小时前
快速搭建 TypeScript 开发环境
typescript
jonjia2 小时前
TypeScript 的奇怪之处
typescript
jonjia2 小时前
类型派生
typescript
jonjia2 小时前
开发流程中的 TypeScript
typescript
jonjia2 小时前
设计你的类型
typescript
jonjia2 小时前
Total TypeScript 精要
typescript