注解与断言

11 阅读27分钟

注解与断言

理解 TypeScript 的注解与值:学习变量和值如何与类型注解以及类型断言(如 'as' 和 '@ts-expect-error')交互

纵观本书,我们一直在使用相对简单的类型注解。我们已经了解了变量注解,它帮助 TypeScript 知道一个变量应该是什么类型:

let name: string;
name = "Waqas";

我们也看到了如何为函数参数和返回类型指定类型:

function greet(name: string): string {
  return \`你好, ${name}!\`;
}

这些注解是对 TypeScript 的指令,告诉它某个东西应该是什么类型。如果我们的 greet 函数返回一个 number,TypeScript 将会显示一个错误。我们已经告诉 TypeScript 我们将返回一个 string,所以它期望得到一个 string

但有些时候我们想遵循这种模式。有时,我们想让 TypeScript 自己推断出来。

而有时,我们想对 TypeScript “撒谎”。

在本章中,我们将探讨更多通过注解和断言与 TypeScript 编译器沟通的方法。

注解变量与注解值

在 TypeScript 中,注解变量和注解是有区别的。它们之间的冲突方式可能会令人困惑。

当你注解一个变量时,变量的类型注解说了算

让我们再回顾一下本书中一直看到的变量注解。

在这个例子中,我们声明了一个变量 config,并将其注解为一个键为字符串、值为 Color 类型的 Record

type Color =

  | string

  | {

      r: number;

      g: number;

      b: number;

    };

const config: Record<string, Color> = {

  foreground: { r: 255, g: 255, b: 255 },

  background: { r: 0, g: 0, b: 0 },

  border: "transparent",

};

这里,我们注解了一个变量。我们说 config 是一个键为字符串、值为 ColorRecord。这很有用,因为如果我们指定一个不匹配该类型的 Color,TypeScript 将会显示一个错误:

const config: Record<string, Color> = {

  border: { incorrect: 0, g: 0, b: 0 },
// 对象字面量只能指定已知的属性,但“incorrect”不在类型“{ r: number; g: number; b: number; }”中。2353
};

但是这种方法有一个问题。如果我们尝试访问任何键,TypeScript 会感到困惑:

config.foreground.r;
// 属性 'r' 在类型 'Color' 上不存在。
//   属性 'r' 在类型 'string' 上不存在。2339

首先,它不知道 foreground 是在对象上定义的。其次,它不知道 foregroundColor 类型的 string 版本还是对象版本。

这是因为我们告诉 TypeScript config 是一个具有任意数量字符串键的 Record。我们注解了变量,但实际的的类型信息被丢弃了。这是一个重点——当你注解一个变量时,TypeScript 将会:

  1. 确保传递给变量的值与注解匹配。
  2. 忘记值的具体类型。

这样做有一些好处——我们可以向 config 添加新的键,TypeScript 不会报错:

config.primary = "red";

但这并不是我们真正想要的——这是一个不应该被更改的配置对象。

没有注解时,值的类型说了算

解决这个问题的一种方法是去掉变量注解。

const config = {

  foreground: { r: 255, g: 255, b: 255 },

  background: { r: 0, g: 0, b: 0 },

  border: "transparent",

};

因为没有变量注解,config 的类型被推断为所提供值的类型。

但是现在我们失去了检查 Color 类型是否正确的能力。我们可以在 foreground 键上添加一个 number,TypeScript 也不会报错:

const config = {

  foreground: 123,

};

所以我们似乎陷入了僵局。我们既想推断值的类型,又想将其约束为某种形状。

使用 satisfies 注解值

satisfies 操作符是一种告诉 TypeScript 一个值必须满足某些标准,但仍然允许 TypeScript 推断其类型的方法。

让我们用它来确保我们的 config 对象具有正确的形状:

const config = {

  foreground: { r: 255, g: 255, b: 255 },

  background: { r: 0, g: 0, b: 0 },

  border: "transparent",

} satisfies Record<string, Color>;

现在,我们两全其美了。这意味着我们可以毫无问题地访问键:

config.foreground.r;

config.border.toUpperCase();

但我们也告诉 TypeScript config 必须是一个键为字符串、值为 ColorRecord。如果我们尝试添加一个不匹配此形状的键,TypeScript 将显示错误:

const config = {

  primary: 123,
// 类型 'number' 不能赋值给类型 'Color'。2322
} satisfies Record<string, Color>;

当然,我们现在失去了在不让 TypeScript 报错的情况下向 config 添加新键的能力:

config.somethingNew = "red";
// 属性 'somethingNew' 在类型 '{}' 上不存在。2339

因为 TypeScript 现在将 config 推断为仅仅一个具有固定键集合的对象。

让我们回顾一下:

  • 当你使用变量注解时,变量的类型注解说了算。
  • 当你不使用变量注解时,值的类型说了算。
  • 当你使用 satisfies 时,你可以告诉 TypeScript 一个值必须满足某些标准,但仍然允许 TypeScript 推断该值的类型。
使用 satisfies 收窄值的类型

关于 satisfies 的一个常见误解是它不影响值的类型。这不完全正确——在某些情况下,satisfies 确实有助于将值收窄到某个特定类型。

让我们看这个例子:

const album = {

  format: "Vinyl",

};

这里,我们有一个 album 对象,它有一个 format 键。正如我们从关于可变性的章节中知道的,TypeScript 会将 album.format 推断为 string。我们想确保 format 是三个值之一:CDVinylDigital

我们可以给它一个变量注解:

type Album = {

  format: "CD" | "Vinyl" | "Digital";

};

const album: Album = {

  format: "Vinyl",

};

但是现在,album.format 的类型是 "CD" | "Vinyl" | "Digital"。如果我们想把它传递给一个只接受 "Vinyl" 的函数,这可能会有问题。

相反,我们可以使用 satisfies

const album = {

  format: "Vinyl",

} satisfies Album;

现在,album.format 被推断为 "Vinyl",因为我们告诉 TypeScript album 满足 Album 类型。所以,satisfies 正在将 album.format 的值收窄为一个特定的类型。

断言:强制值的类型

有时,TypeScript 推断类型的方式并不完全符合我们的期望。我们可以在 TypeScript 中使用断言来强制将值推断为某个特定类型。

as 断言

as 断言是一种告诉 TypeScript 你比它更了解某个值的方式。它是一种覆盖 TypeScript 类型推断并告诉它将值视为不同类型的方法。

让我们看一个例子。

想象一下,你正在构建一个网页,该网页的 URL 查询字符串中包含一些信息。

你碰巧知道用户在导航到此页面时,URL 中必须传递 ?id=some-id

const searchParams = new URLSearchParams(window.location.search);

const id = searchParams.get("id");
      // const id: string | null

但是 TypeScript 不知道 id 永远是一个字符串。它认为 id 可能是一个字符串或 null

所以,让我们强制它。我们可以在 searchParams.get("id") 的结果上使用 as 来告诉 TypeScript 我们知道它永远是一个字符串:

const id = searchParams.get("id") as string;
      // const id: string

现在 TypeScript 知道 id 永远是一个字符串,我们可以这样使用它。

这个 as 有点不安全!如果 id 实际上没有在 URL 中传递,它在运行时将是 null,但在编译时是 string。这意味着如果我们对 id 调用 .toUpperCase(),我们的应用程序将会崩溃。

但在我们确实比 TypeScript 更了解代码行为的情况下,它很有用。

一种替代语法

作为 as 的替代方案,你可以在值前面加上用尖括号包裹的类型:

const id = <string>searchParams.get("id");

这种方式不如 as 常见,但行为完全相同。as 更常见,所以最好使用它。

as 的局限性

as 在使用上有一些限制。它不能用于在不相关的类型之间进行转换。

考虑这个例子,其中 as 用于断言一个字符串应该被视为一个数字:

const albumSales = "Heroes" as number;
// 类型 'string' 到类型 'number' 的转换可能是一个错误,因为这两种类型没有充分的重叠。如果这是故意的,请先将表达式转换为 'unknown'。2352

TypeScript 意识到即使我们使用了 as,我们也可能犯了一个错误。错误消息告诉我们字符串和数字没有任何共同的属性,但如果我们真的想这样做,我们可以使用双重 as 断言,首先将字符串断言为 unknown,然后再断言为 number

const albumSales = "Heroes" as unknown as number; // 没有错误

当使用 as unknown as number 进行断言时,红色的波浪线消失了,但这并不意味着操作是安全的。根本没有办法将 "Heroes" 转换成一个有意义的数字。

同样的行为也适用于其他类型。

在这个例子中,Album 接口和 SalesData 接口没有任何共同的属性:

interface Album {

  title: string;

  artist: string;

  releaseYear: number;

}

interface SalesData {

  sales: number;

  certification: string;

}

const paulsBoutique: Album = {

  title: "Paul's Boutique",

  artist: "Beastie Boys",

  releaseYear: 1989,

};

const paulsBoutiqueSales = paulsBoutique as SalesData;
// 类型 'Album' 到类型 'SalesData' 的转换可能是一个错误,因为这两种类型没有充分的重叠。如果这是故意的,请先将表达式转换为 'unknown'。
//   类型 'Album' 缺少类型 'SalesData' 中的以下属性:sales, certification 2352

同样,TypeScript 向我们显示了关于缺少共同属性的警告。

所以,as 确实有一些内置的保护措施。但是通过使用 as unknown as X,你可以轻易地绕过它们。而且因为 as 在运行时不做任何事情,所以它是一种方便地向 TypeScript “撒谎”关于值类型的方式。

非空断言

我们可以使用的另一种断言是非空断言,它通过使用 ! 操作符来指定。这提供了一种快速告诉 TypeScript 一个值不是 nullundefined 的方法。

回到我们之前的 searchParams 例子,我们可以使用非空断言来告诉 TypeScript id 永远不会是 null

const searchParams = new URLSearchParams(window.location.search);

const id = searchParams.get("id")!;

这会强制 TypeScript 将 id 视为字符串,即使它在运行时可能是 null。它等同于使用 as string,但更方便一些。

你也可以在访问一个可能定义也可能未定义的属性时使用它:

type User = {

  name: string;

  profile?: {

    bio: string;

  };

};

const logUserBio = (user: User) => {

  console.log(user.profile!.bio);

};

或者,在调用一个可能未定义的函数时:

type Logger = {

  log?: (message: string) => void;

};

const main = (logger: Logger) => {

  logger.log!("Hello, world!");

};

如果值未定义,这些操作都会在运行时失败。但这是一种方便地向 TypeScript “撒谎”的方式,我们确信它会存在。

非空断言,像其他断言一样,是一个危险的工具。它特别讨厌,因为它只有一个字符长,所以比 as 更容易被忽略。

为了好玩,我喜欢连续用上三四个,确保开发者知道他们正在做的事情有多危险:

// 是的,这个语法是合法的
const id = searchParams.get("id")!!!!;

断言并不是我们向 TypeScript “撒谎”的唯一方式。有几个注释指令可以用来抑制错误。

在本书的练习中,我们已经看到了几个 @ts-expect-error 的例子。这个指令让我们能够告诉 TypeScript 我们期望下一行代码会发生错误。

在这个例子中,我们通过将一个字符串传递给一个期望数字的函数来制造一个错误。

function addOne(num: number) {

  return num + 1;

}

// @ts-expect-error
const result = addOne("one");

但是这个错误并没有在编辑器中显示出来,因为我们告诉 TypeScript 去期望它。

然而,如果我们向函数传递一个数字,错误就会显示出来:

// @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
const result = addOne(1);

所以,TypeScript 期望每个 @ts-expect-error 指令都被使用——即后面跟着一个错误。

令人沮丧的是,@ts-expect-error 不允许你期望一个特定的错误,而只允许期望会发生一个错误。

@ts-ignore

@ts-ignore 指令的行为与 @ts-expect-error 有点不同。它不是期望一个错误,而是忽略任何确实发生的错误。

回到我们的 addOne 例子,我们可以使用 @ts-ignore 来忽略将字符串传递给函数时发生的错误:

// @ts-ignore
const result = addOne("one");

但是如果我们稍后修复了这个错误,@ts-ignore 不会告诉我们它未被使用:

// @ts-ignore
const result = addOne(1); // 这里没有错误!

总的来说,@ts-expect-error@ts-ignore 更有用,因为它会在你修复错误时通知你。这意味着你可以得到一个移除该指令的警告。

@ts-nocheck

最后,@ts-nocheck 指令将完全移除对一个文件的类型检查。

要使用它,请在文件的顶部添加该指令:

// @ts-nocheck

在所有检查都被禁用的情况下,TypeScript 不会向你显示任何错误,但它也无法保护你免受代码运行时可能出现的任何运行时问题。

一般来说,你不应该使用 @ts-nocheck。我个人曾因为没有注意到文件顶部的 @ts-nocheck 而在大型文件中浪费了数小时。

在 TypeScript 开发者的工具箱中,还有一种工具能抑制错误,但它不是注释指令——那就是 as any

as any 是一个极其强大的工具,因为它结合了对 TypeScript 的“谎言” (as) 和一个禁用所有类型检查的类型 (any)。

这意味着你可以用它来抑制几乎任何错误。我们上面的例子?没问题:

const result = addOne({} as any);

as any 将空对象变成了 any,这禁用了所有类型检查。这意味着 addOne 会很乐意接受它。

当有多种方式抑制错误时,我更喜欢使用 as any。错误抑制指令的范围太广——它们针对的是整行代码。这可能导致意外抑制了你并不想抑制的错误:

// @ts-ignore
const result = addone("one");

这里,我们调用的是 addone 而不是 addOne。错误抑制指令会抑制这个错误,但它也会抑制该行可能发生的任何其他错误。

改用 as any 会更精确:

const result = addone("one" as any);
// 找不到名称 'addone'。你是否指的是 'addOne'?2552

现在,你只会抑制你打算抑制的错误。

我们讨论过的每一种错误抑制工具,基本上都是告诉 TypeScript “保持安静”的一种方式。TypeScript 并不会试图限制你尝试让它闭嘴的频率。完全有可能每次遇到错误时,你都可以用 @ts-ignoreas any 来抑制它。

采用这种方法会限制 TypeScript 的用处。你的代码会编译通过,但你可能会遇到更多的运行时错误。

但是有些时候抑制错误是个好主意。让我们探讨几种不同的场景。

关于 TypeScript,重要的一点是要记住,你实际上是在编写 JavaScript。

编译时和运行时之间的这种脱节意味着类型有时可能是错误的。这可能意味着你比 TypeScript 更了解运行时的代码。

当第三方库没有良好的类型定义时,或者当你正在处理 TypeScript 难以理解的复杂模式时,就可能发生这种情况。

错误抑制指令因此而存在。它们让你能够弥补 TypeScript 和它产生的 JavaScript 之间有时出现的差异。

但是这种凌驾于 TypeScript 之上的感觉可能很危险。所以,让我们把它与一种非常相似的感觉进行比较:

当 TypeScript 表现得“愚蠢”时

有些模式比其他模式更适合类型化。更动态的模式可能更难让 TypeScript 理解,并且会导致你抑制更多的错误。

一个简单的例子是构造一个对象。在 JavaScript 中,这两种模式之间没有真正的区别:

// 静态
const obj = {
  a: 1,
  b: 2,
};

// 动态
const obj2 = {};
obj2.a = 1; // 属性 'a' 在类型 '{}' 上不存在。2339
obj2.b = 2; // 属性 'b' 在类型 '{}' 上不存在。2339

在第一种模式中,我们通过传入键和值来构造一个对象。在第二种模式中,我们构造一个空对象,然后再添加键和值。第一种模式是静态的,第二种是动态的。

但是在 TypeScript 中,第一种模式要容易得多。TypeScript 可以将 obj 的类型推断为 { a: number, b: number }。但它无法推断 obj2 的类型——它只是一个空对象。实际上,当你尝试这样做时会得到错误。

但是如果你习惯于以动态方式构造对象,这可能会令人沮丧。你知道 obj2 会有 ab 键,但 TypeScript 不知道。

在这些情况下,很容易想通过使用 as 来告诉 TypeScript 你知道自己在做什么,从而稍微变通一下规则:

const obj2 = {} as { a: number; b: number };
obj2.a = 1;
obj2.b = 2;

这与第一种情况 subtly 不同,在第一种情况中,你比 TypeScript 知道得更多。在这种情况下,你可以做一个简单的运行时重构来让 TypeScript满意并避免抑制错误。

你使用 TypeScript 的经验越丰富,就越能发现这些模式。你将能够发现 TypeScript 缺乏关键信息(需要 as)的时候,或者你使用的模式没有让 TypeScript 正常工作的时候。

所以,如果你想抑制一个错误,看看是否可以重构你的代码,使其成为 TypeScript 更能理解的模式。毕竟,顺流而下总比逆流而上容易。

假设你已经编码了几个小时。一个未读的 Slack 消息通知在你眼前闪烁。这个功能除了需要添加一些类型之外,几乎已经完成了。20 分钟后你有一个会议。然后 TypeScript 显示了一个你看不懂的错误。

TypeScript 的错误可能非常难以阅读。它们可能很长,层次很多,并且充满了你从未听说过的类型引用。

正是在这个时候,TypeScript 可能会让人感到最沮丧。这足以让许多开发者永远放弃 TypeScript。

所以,你抑制了这个错误。你添加了一个 @ts-ignore 或一个 as any,然后继续前进。

几周后,一个 bug 被报告了。你最终回到了代码库的同一区域。然后你把错误追踪到了你抑制的那一行。

通过抑制错误节省的时间,最终会反噬你。你不是在节省时间,而是在借用时间。

正是在这种你看不懂错误的情况下,我建议你坚持下去。TypeScript 正试图与你沟通。尝试重构你的运行时代码。使用 IDE 超能力章节中提到的所有工具来调查错误中提到的类型。

把修复 TypeScript 错误投入的时间看作是对自己的投资。你既在修复未来潜在的 bug,也在提升自己的理解水平。

练习

练习 1:向 TypeScript 提供额外信息

这个 handleFormData 函数接受一个参数 e,其类型为 SubmitEvent,这是 DOM 类型定义中的一个全局类型,在表单提交时会发出。

在函数内部,我们使用 SubmitEvent 上可用的方法 e.preventDefault() 来阻止表单的默认提交行为。然后我们尝试用 e.target 创建一个新的 FormData 对象 data

// @lib: dom,es2023,dom.iterable
// @errors: 2345
const handleFormData = (e: SubmitEvent) => {
  e.preventDefault();
  const data = new FormData(e.target);
  const value = Object.fromEntries(data.entries());
  return value;
};

在运行时,这段代码完美无缺。然而,在类型层面,TypeScript 在 e.target 下显示了一个错误。你的任务是向 TypeScript 提供额外的信息以解决这个错误。

练习 2:用断言解决问题

这里我们将重温一个之前的练习,但用不同的方式解决它。

findUsersByName 函数的第一个参数是 searchParams,其中 name 是一个可选的字符串属性。第二个参数是 users,它是一个包含 idname 属性的对象数组:

const findUsersByName = (
  searchParams: { name?: string },
  users: {
    id: string;
    name: string;
  }[],
) => {
  if (searchParams.name) {
    return users.filter((user) => user.name.includes(searchParams.name));
// 类型 'string | undefined' 的参数不能赋给类型 'string' 的参数。
//   类型 'undefined' 不能赋给类型 'string'。2345
  }
  return users;
};

如果 searchParams.name 已定义,我们想用这个 name 来过滤 users 数组。你的挑战是调整代码,使错误消失。

之前我们通过将 searchParams.name 提取到一个 const 变量中并对其进行检查来解决了这个挑战。

然而,这次你需要用两种不同的方法来解决它:一次用 as,一次用非空断言。

注意,这比之前的解决方案稍微不安全一些,但它仍然是一个值得学习的好技巧。

练习 3:强制执行有效配置

我们又回到了包含 developmentproductionstagingconfigurations 对象。这些成员中的每一个都包含与其环境相关的特定设置:

const configurations = {
  development: {
    apiBaseUrl: "http://localhost:8080",
    timeout: 5000,
  },
  production: {
    apiBaseUrl: "https://api.example.com",
    timeout: 10000,
  },
  staging: {
    apiBaseUrl: "https://staging.example.com",
    timeout: 8000,
    // @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
    notAllowed: true,
  },
};

我们还有一个 Environment 类型以及一个通过的测试用例,用于检查 Environment 是否等于 "development" | "production" | "staging"

type Environment = keyof typeof configurations;

type test = Expect<
  Equal<Environment, "development" | "production" | "staging">
>;

尽管测试用例通过了,但在 configurationsstaging 对象中我们有一个错误。我们期望在 notAllowed: true 上出现错误,但 @ts-expect-error 指令不起作用,因为 TypeScript 没有识别出 notAllowed是不允许的。

你的任务是确定一种合适的方式来注解我们的 configurations 对象,以便从中保留准确的 Environment 推断,同时对不允许的成员抛出错误。提示:考虑使用一个辅助类型,它允许你指定数据形状。

练习 4:变量注解 vs. as vs. satisfies

在这个练习中,我们将研究 TypeScript 中的三种不同设置类型:变量注解、assatisfies

第一个场景包括将 const obj 声明为一个空对象,然后将键 ab 应用于它。使用 as Record<string, number>,我们期望 obja 的类型是一个数字:

const obj = {} as Record<string, number>;
obj.a = 1;
obj.b = 2;
type test = Expect<Equal<typeof obj.a, number>>;

其次,我们有一个 menuConfig 对象,它被赋予了一个以 string 作为键的 Record 类型。menuConfig 期望要么有一个包含 labellink 属性的对象,要么有一个包含 labelchildren 属性的对象,其中 children 包含具有 labellink 的对象数组:

const menuConfig: Record<
  string,
  | {
      label: string;
      link: string;
    }
  | {
      label: string;
      children: {
        label: string;
        link: string;
      }[];
    }
> = {
  home: {
    label: "Home",
    link: "/home",
  },
  services: {
    label: "Services",
    children: [
      {
        label: "Consulting",
        link: "/services/consulting",
      },
      {
        label: "Development",
        link: "/services/development",
      },
    ],
  },
};

type tests = [
  Expect<Equal<typeof menuConfig.home.label, string>>,
  Expect<
    Equal<
      typeof menuConfig.services.children,
// 属性 'children' 在类型 '{ label: string; link: string; } | { label: string; children: { label: string; link: string; }[]; }' 上不存在。
//   属性 'children' 在类型 '{ label: string; link: string; }' 上不存在。2339
      {
        label: string;
        link: string;
      }[]
    >
  >,
];

在第三个场景中,我们试图将 satisfiesdocument.getElementById('app')HTMLElement 一起使用,但这导致了错误:

// 第三个场景
const element = document.getElementById("app") satisfies HTMLElement;
// 类型 'HTMLElement | null' 不满足期望的类型 'HTMLElement'。
//   类型 'null' 不能赋值给类型 'HTMLElement'。1360

type test3 = Expect<Equal<typeof element, HTMLElement>>;
// 类型 'false' 不满足约束 'true'。2344

你的工作是重新排列注解以纠正这些问题。

在本练习结束时,你应该分别使用过一次 as、变量注解和 satisfies

练习 5:创建一个深度只读对象

这里我们有一个 routes 对象:

const routes = {
  "/": {
    component: "Home",
  },
  "/about": {
    component: "About",
    // @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
    search: "?foo=bar",
  },
};

// @ts-expect-error
// 未使用的 '@ts-expect-error' 指令。2578
routes["/"].component = "About";

/about 键下添加 search 字段时,它应该引发一个错误,但目前没有。我们还期望一旦创建了 routes 对象,它就不能被修改。例如,将 About 赋给 Home component 应该导致错误,但 @ts-expect-error 指令告诉我们没有问题。

在测试中,我们期望访问 routes 对象的属性应返回 HomeAbout,而不是将它们解释为字面量,但这两个测试目前都失败了:

type tests = [
  Expect<Equal<(typeof routes)["/"]["component"], "Home">>,
// 类型 'false' 不满足约束 'true'。2344
  Expect<Equal<(typeof routes)["/about"]["component"], "About">>,
// 类型 'false' 不满足约束 'true'。2344
];

你的任务是更新 routes 对象的类型定义,以便解决所有错误。这将需要你使用 satisfies 以及另一个确保对象是深度只读的注解。

解决方案 1:向 TypeScript 提供额外信息

我们在这个挑战中遇到的错误是 EventTarget | null 类型与所需的 HTMLFormElement 类型的参数不兼容。问题在于这些类型不匹配,并且不允许 null

// @lib: dom,es2023,dom.iterable
// @errors: 2345
const handleFormData = (e: SubmitEvent) => {
  e.preventDefault();
  const data = new FormData(e.target);
  const value = Object.fromEntries(data.entries());
  return value;
};

首先,最重要的是确保 e.target 不是 null。

使用 as

我们可以使用 as 关键字将 e.target 转换为特定类型。

然而,如果我们将其转换为 EventTarget,错误仍将继续发生:

// @lib: dom,es2023,dom.iterable
// @errors: 2345
const handleFormData = (e: SubmitEvent) => {
  e.preventDefault();
  const data = new FormData(e.target as EventTarget);
  const value = Object.fromEntries(data.entries());
  return value;
};

因为我们知道代码在运行时可以工作并且有测试覆盖它,所以我们可以强制 e.target 的类型为 HTMLFormElement

const data = new FormData(e.target as HTMLFormElement);

或者,我们可以创建一个新变量 target,并将转换后的值赋给它:

const target = e.target as HTMLFormElement;
const data = new FormData(target);

无论哪种方式,这个更改都解决了错误,并且 target 现在被推断为 HTMLFormElement,代码按预期工作。

使用 as any

一个更快的解决方案是对 e.target 变量使用 as any,告诉 TypeScript 我们不关心该变量的类型:

const data = new FormData(e.target as any);

虽然使用 as any 可以让我们更快地绕过错误消息,但它确实有其缺点。

例如,我们将无法利用自动完成功能,也无法对来自 HTMLFormElement 类型的其他 e.target 属性进行类型检查。

当遇到这种情况时,最好使用你能想到的最具体的 as 断言。这不仅向 TypeScript,也向可能阅读你代码的其他开发人员传达了你对 e.target 是什么的清晰理解。

解决方案 2:用断言解决问题

findUsersByName 函数内部,TypeScript 因为一个奇怪的原因对 searchParams.name 报错。

想象一下,如果 searchParams.name 是一个 getter,它会随机返回 stringundefined

const searchParams = {
  get name() {
    return Math.random() > 0.5 ? "John" : undefined;
  },
};

现在,TypeScript 无法确定 searchParams.name 永远是一个 string。这就是为什么它在 filter 函数内部报错的原因。

这就是为什么我们之前能够通过将 searchParams.name 提取到一个常量中并对其进行检查来解决这个问题——这保证了 name 将是一个字符串。

然而,这次我们将以不同的方式解决它。

目前,searchParams.name 的类型是 string | undefined。我们想告诉 TypeScript 我们比它知道得更多,我们知道 searchParams.namefilter 回调函数内部永远不会是 undefined

const findUsersByName = (
  searchParams: { name?: string },
  users: {
    id: string;
    name: string;
  }[],
) => {
  if (searchParams.name) {
    return users.filter((user) => user.name.includes(searchParams.name));
// 类型 'string | undefined' 的参数不能赋给类型 'string' 的参数。
//   类型 'undefined' 不能赋给类型 'string'。2345
  }
  return users;
};
添加 as string

解决这个问题的一种方法是向 searchParams.name 添加 as string

const findUsersByName = (
  searchParams: { name?: string },
  users: {
    id: string;
    name: string;
  }[],
) => {
  if (searchParams.name) {
    return users.filter((user) =>
      user.name.includes(searchParams.name as string),
    );
  }
  return users;
};

这移除了 undefined,现在它只是一个 string

添加非空断言

解决这个问题的另一种方法是向 searchParams.name 添加一个非空断言。这是通过向我们试图访问的属性添加一个 ! 后缀操作符来完成的:

const findUsersByName = (
  searchParams: { name?: string },
  users: {
    id: string;
    name: string;
  }[],
) => {
  if (searchParams.name) {
    return users.filter((user) => user.name.includes(searchParams.name!));
  }
  return users;
};

! 操作符告诉 TypeScript 从变量中移除任何 nullundefined 类型。这将使我们只剩下 string

这两种解决方案都将消除错误并允许代码按预期工作。但是它们都不能保护我们免受那个随机返回 string | undefined 的阴险的 get 函数的影响。

由于这是一种非常罕见的情况,我们甚至可以说 TypeScript 在这里有点过度保护了。所以,断言似乎是正确的选择。

解决方案 3:强制执行有效配置

第一步是确定我们的 configurations 对象的结构。

在这种情况下,它是一个 Record 是合理的,其中键将是 string,值将是一个具有 apiBaseUrltimeout 属性的对象。

const configurations: Record<
  string,
  {
    apiBaseUrl: string;
    timeout: number
  }
> = {
  // ...
};

这个更改使得 @ts-expect-error 指令按预期工作,但是我们现在有一个与 Environment 类型未被正确推断相关的错误:

type Environment = keyof typeof configurations;
         // type Environment = string

type test = Expect<
  Equal<Environment, "development" | "production" | "staging">
// 类型 'false' 不满足约束 'true'。2344
>;

我们需要确保 configurations 仍然被推断为其类型,同时还要对传递给它的内容进行类型检查。

这是 satisfies 关键字的完美应用场景。

我们不再将 configurations 对象注解为 Record,而是使用 satisfies 关键字进行类型约束:

const configurations = {
  development: {
    apiBaseUrl: "http://localhost:8080",
    timeout: 5000,
  },
  production: {
    apiBaseUrl: "https://api.example.com",
    timeout: 10000,
  },
  staging: {
    apiBaseUrl: "https://staging.example.com",
    timeout: 8000,
    // @ts-expect-error
    notAllowed: true,
  },
} satisfies Record<
  string,
  {
    apiBaseUrl: string;
    timeout: number;
  }
>;

这允许我们指定传递给我们配置对象的值必须遵守类型中定义的标准,同时仍然允许类型系统为我们的开发、生产和预发环境推断出正确的类型。

解决方案 4:变量注解 vs. as vs. satisfies

让我们逐步解决 satisfiesas 和变量注解的方案。

何时使用 satisfies

对于第一个使用 Record 的场景,satisfies 关键字不起作用,因为我们不能向空对象添加动态成员。

const obj = {} satisfies Record<string, number>;
obj.a = 1; // 属性 'a' 在类型 '{}' 上不存在。2339

在第二个 menuConfig 对象的场景中,我们一开始就遇到了关于 menuConfig.homemenuConfig.services 在两个成员上都不存在的错误。

这是一个线索,表明我们需要使用 satisfies 来确保一个值被检查而不改变其推断类型:

const menuConfig = {
  home: {
    label: "Home",
    link: "/home",
  },
  services: {
    label: "Services",
    children: [
      {
        label: "Consulting",
        link: "/services/consulting",
      },
      {
        label: "Development",
        link: "/services/development",
      },
    ],
  },
} satisfies Record<
  string,
  | {
      label: string;
      link: string;
    }
  | {
      label: string;
      children: {
        label: string;
        link: string;
      }[];
    }
>;

通过这样使用 satisfies,测试按预期通过。

只是为了检查第三个场景,satisfies 不适用于 document.getElementById("app"),因为它被推断为 HTMLElement | null

const element = document.getElementById("app") satisfies HTMLElement;
// 类型 'HTMLElement | null' 不满足期望的类型 'HTMLElement'。
//   类型 'null' 不能赋值给类型 'HTMLElement'。1360
何时使用 as

如果我们在第三个例子中尝试使用变量注解,我们会得到与 satisfies 相同的错误:

const element: HTMLElement = document.getElementById("app");
// 类型 'HTMLElement | null' 不能赋值给类型 'HTMLElement'。
//   类型 'null' 不能赋值给类型 'HTMLElement'。2322

通过排除法,as 是这个场景的正确选择:

const element = document.getElementById("app") as HTMLElement;

通过这个更改,element 被推断为 HTMLElement

使用变量注解

这就把我们带到了第一个场景,其中使用变量注解是正确的选择:

const obj: Record<string, number> = {};

请注意,我们可以在这里使用 as,但这不太安全,并且可能会导致复杂化,因为我们强制一个值成为某个类型。变量注解只是将一个变量标记为该特定类型,并检查传递给它的任何内容,这是更正确、更安全的方法。

通常,当你在 as 或变量注解之间有选择时,请选择变量注解。

核心要点

本练习的关键在于掌握何时使用 assatisfies 和变量注解的心智模型:

当你想要告诉 TypeScript 你比它知道得更多时,使用 as

当你想要确保一个值被检查而不改变该值的推断类型时,使用 satisfies

其余时间,使用变量注解。

解决方案 5:创建一个深度只读对象

我们一开始在 routes 内部有一个 @ts-expect-error 指令没有按预期工作。

因为我们希望配置对象具有某种形状,同时仍然能够访问它的某些部分,所以这是 satisfies 的一个完美用例。

routes 对象的末尾,添加一个 satisfies,它将是一个键为 string、值为一个带有 string 类型 component 属性的对象的 Record

const routes = {
  "/": {
    component: "Home",
  },
  "/about": {
    component: "About",
    // @ts-expect-error
    search: "?foo=bar",
  },
} satisfies Record<
  string,
  {
    component: string;
  }
>;

这个更改解决了 routes 对象内部 @ts-expect-error 指令的问题,但是我们仍然有一个与 routes 对象不是只读相关的错误。

要解决这个问题,我们需要对 routes 对象应用 as const。这将使 routes 变为只读并添加必要的不可变性。

如果我们尝试在 satisfies 之后添加 as const,我们会得到以下错误:

const routes = {
  // ...内容
} satisfies Record< // 'const' 断言只能应用于枚举成员的引用,或者字符串、数字、布尔值、数组或对象字面量。1355
  string,
  {
    component: string;
  }
> as const;

换句话说,as const 只能应用于值而不是类型。

使用 as const 的正确方法是将其放在 satisfies 之前:

const routes = {
  // routes 如前
} as const satisfies Record<
  string,
  {
    component: string;
  }
>;

现在我们的测试按预期通过了。

当您需要配置对象具有特定形状并希望强制实施不可变性时,这种结合 as constsatisfies 的设置非常理想。