TypeScript:为什么不应该用抛出错误来控制程序流程

473 阅读7分钟

简而言之:

  • 错误和try/catch机制有助于防止错误和其他意外行为,并从中恢复。
  • 错误 并且try/catch对于在代码中表示和处理预期的故障状态不是最佳的。

Error行动中

这样的代码有什么问题?

/** A crude emulation of a database of user IDs */
const userDatabase = [1, 2, 3];

const getUser = (id: number) => {
  if (userDatabase.includes(id)) {
    return id;
  } else {
    throw new Error(`User not found with id: ${id}`);
  }
};

可以说没毛病。毕竟你可以称它为下游非常好:

const doSomethingWithUser = () => {
  const user = getUser(1);
  console.log(user);
};

但是,如果您传入的 ID 在我们的临时用户数据库中找不到,会发生什么情况?该getUser函数将throw和错误,该doSomethingWithUser函数不处理。相反,它会冒泡到任何doSomethingWithUser被调用的地方。

这个例子看起来很简单,可以解决,因为你可以try/catch在调用者中使用一个块:

const doSomethingWithUser = () => {
  try {
    const user = getUser(10);
    console.log(user);
  } catch (e) {
    // Do something with the error
  }
};

现在,在catch块中,您可以防止错误进一步冒泡,而是以有意义的方式处理这种情况。也许你想返回一些合理的默认值,也许undefinedor null,也许记录一条错误消息 - 任何东西都是一种选择。

那么有问题吗?

问题:错误不是类型签名的一部分

如果我们查看 的推断类型签名getUser,我们会发现它是(id: string) => number. 换句话说,编译器告诉我们这个函数将返回一个 类型的值number。这也是我们手动输入函数的方式。

没有迹象表明函数可以抛出错误,更不用说函数可以抛出什么样的错误了。为了了解这一点,需要单独记录它,或者需要检查函数源代码以确定其行为。这违反了信息隐藏的原则,因为界面本身并没有解释如何使用该功能。

TypeScript 的类型系统无法将可以从一段代码中抛出的函数编码为有意义的类型表示,至少到目前为止是这样。这意味着编译器无法指示程序员应该为抛出的错误做好准备并相应地进行处理。

当你写一个catch (err) {}块时,的类型err总是unknown。这是上述原因的结果,也是在 TypeScript 和底层 JavaScript 中,当错误发生时会抛出错误的事实。您的函数可能会抛出代码中明确抛出的错误,但它也可能存在错误并抛出意外TypeError: undefined is not a function或类似的东西。我们将在下面讨论错误边界作为解决方案。

在我们愚蠢的小例子中,这个问题可能看起来并不那么糟糕,但实际上在抛出错误的函数与应该能够优雅地处理错误的调用代码段之间可能存在很深的调用堆栈。例如,如果您安装了一个提供该getUser功能的外部库,您可能无法了解该库的内部结构。即使您这样做了,理想情况下类型系统也会为您提供足够好的 API 模式描述以供使用,隐藏有关详细级别实现的信息。

问题是代码库越大,处理它的团队越大。您可能会查看其他人编写的函数的类型签名并想象它适合您的用例,但对其行为的关键部分一无所知。

该怎么办?

我们可以用可辨别联合来解决信息隐藏问题。但是为了使这个逻辑合理,我们需要应用合理的错误边界

有区别的联合以获得更好的编译器支持

为了让编译器更加了解预期的失败情况并帮助程序员处理这些情况,我们可以从函数中返回失败而不是抛出它们。现在,函数可以返回成功或失败,两者都带有相关数据。

type GetUserFailure = { _t: "failure" };
type GetUserSuccess = { _t: "success"; id: number };
type GetUserResult = GetUserFailure | GetUserSuccess;

const getUser = (id: number): GetUserResult => {
  if (userDatabase.includes(id)) {
    return { _t: "success", id };
  } else {
    return { _t: "failure" };
  }
};

不会抛出任何错误,而是返回一个成功或失败的类型。然后我们可以使用鉴别器属性(在本例中为 )_t来识别我们得到的结果。现在我们可以使用任何传统的条件机制:if/else语句、三元运算符或switch语句。

const doSomethingWithUser = () => {
  const result = getUser(10);
  switch (result._t) {
    case "failure":
      // There was no user, so result is a failure,
      // handle the situation somehow
      break;
    case "success":
      // There was a user, so result is a success,
      // and we have an ID
      const user = result.id;
      console.log(user);
      break;
  }
};

仅通过检查函数的类型签名 ,(id: number) => GetUserResult我们就知道调用函数有两种可能的结果,我们应该在调用代码中处理这两种结果。

这还不是全部,因为我们不必将自己局限于两种选择。在现实世界中,我们的用户数据库可能不是硬编码数组,而是我们需要调用的一些外部数据存储。我们可以很容易地将其表达为它自己的场景,如下所示:

type ConnectionFailure = { _t: "connection-failure" };
type GetUserFailure = { _t: "user-not-found" };
type GetUserSuccess = { _t: "success"; id: number };
type GetUserResult = ConnectionFailure | GetUserFailure | GetUserSuccess;

const getUser = (id: number): GetUserResult => {
  // Connect to the database, fail on 10% of the calls
  if (Math.random() < 0.1) {
    return { _t: "connection-failure" };
  }

  if (userDatabase.includes(id)) {
    return { _t: "success", id };
  } else {
    return { _t: "user-not-found" };
  }
};

然后我们可以调用该函数并仅使用一个逻辑分支来处理不同的结果:

const doSomethingWithUser = () => {
  const result = getUser(10);
  switch (result._t) {
    case "connection-failure":
      // There was a connection failure,
      // handle the situation somehow
      break;
    case "user-not-found":
      // There was no user, so result is a failure,
      // handle the situation somehow
      break;
    case "success":
      // There was a user, so result is a success,
      // and we have an ID
      const user = result.id;
      console.log(user);
      break;
  }
};

请注意,这里我们现在在返回类型联合中有三个不同的“顶级结果”。或者,我们可以将所有事情归为成功或失败,并有不同的失败子类型。

快速旁白:Either monad

那些熟悉更多函数式语言的人现在可能会在屏幕前大声疾呼 monad。事实上,如果我们想比使用有区别的联合更进一步,我们可以应用Either monads。它们带有很多有用的工具,比如单子模式匹配和链接操作的可能性。我们在 Swappie 的团队中有很好的使用经验fp-ts,甚至认为最初的学习曲线可能很陡峭,但好处是有用的。您可以在网络上的各种博客文章中阅读更多关于 monad 的信息

处理意外错误的错误边界

在函数返回类型签名中编码预期的错误类型似乎很有用,但对于意外错误呢?毕竟,如果我们需要将预期的失败作为返回类型的一部分来处理,并且try/catch无论如何仍然需要处理意外的失败,我们会更好吗?

是和不是。as any从某种意义上说,由于底层 JavaScript 的动态特性,以及在善意的 TypeScript 代码中无害地查看任何地方的能力,我们可能会在代码中的任何地方看到意外的运行时错误。但是从某种意义上说是的,通过一些明智的架构决策,我们可以避免到处泄露这个问题,并在有意义的地方应用错误边界。

错误边界本质上是软件架构中的一个层,您可以在其中捕获调用堆栈中更高层可能发生的任何错误,并防止它们进一步冒泡。在实践中,为了我们的目的,这意味着有一个try/catch适当的位置,并让catch块处理潜在的错误。这可能意味着返回一个已知的故障类型,记录一条错误消息,或者什么也不做,具体取决于用例。

在我们的示例中,我们可以getUser通过映射任何未知错误来变成错误边界:

type ConnectionFailure = { _t: "connection-failure" };
type GetUserFailure = { _t: "user-not-found" };
type UnknownFailure = { _t: "unknown-failure"; err: unknown };
type GetUserSuccess = { _t: "success"; id: number };
type GetUserResult =
  | ConnectionFailure
  | GetUserFailure
  | UnknownFailure
  | GetUserSuccess;

const getUser = (id: number): GetUserResult => {
  try {
    // Connect to the database, fail on 10% of the calls
    if (Math.random() < 0.1) {
      return { _t: "connection-failure" };
    }

    if (userDatabase.includes(id)) {
      return { _t: "success", id };
    } else {
      return { _t: "user-not-found" };
    }
  } catch (err) {
    return { _t: "unknown-failure", err };
  }
};

现在整个事情都包含在一个 中try/catch,这意味着即使是意外的错误也会在块中得到处理catch。在我们的小示例中,这可能看起来有些过分,因为代码非常有限。但是假设上游有人说了一些愚蠢的话

const userDatabase = {} as number[];

现在我们善意的代码有一个错误:

if (userDatabase.includes(id)) {/* ... */}
// => Uncaught TypeError: userDatabase.includes is not a function

没有try/catchthis 就会冒泡到我们函数的调用者。现在,该函数充当错误边界,我们可以相信它不会冒出错误。该示例显然是一个小而琐碎的示例,在更现实的情况下,您可以想象存在更多细微错误的空间。