首页
Preview

TypeScript中处理错误的5条准则

处理错误是软件工程的重要部分。

定义并具备如何处理错误的强有力指导方针,在开发功能时会让你的生活变得更加容易,而且,当事情出错时更加重要!

在 Orus(我们试图重新定义专业保险)中,随着时间的推移,我们量身定制了一种对我们很有效的错误策略,我们认为这可能是有用的分享。

虽然本文主要是关于 TypeScript 中的错误处理,但我们将介绍的一些原则相当通用,也适用于其他语言。

话不多说,这是我们的五个错误处理戒律:

  • #1:确保错误是……错误
  • #2:不要丢失堆栈跟踪
  • #3:使用常量错误消息
  • #4:提供正确的上下文
  • #5:不要为预计会发生的问题抛出错误

这激起了你的兴趣吗?如果是,请继续阅读!

戒律1:确保错误是……错误

在神奇的 JavaScript 世界中,你可能没有意识到,但你可以抛出任何东西,不仅仅是 Error 实例。

function throwNumber() {
  throw 123
}

try {
  throwNumber()
} catch (err) {
  console.log(err) // 123
}

尽管这很有趣并允许一些巧妙的用例(例如,即将推出的 React Suspenses 就建立在我们可以抛出承诺的事实上),但这不是一个好主意!实际上,有一些问题:

  • 没有附加堆栈跟踪,因此抛出的“错误”失去了很多有用性
  • 在调用方面,几乎总是期望实际的 Error 实例。在野外,经常看到不加检查 err 类型的 err.message 的使用方式,这并不罕见

为了确保你在这个问题上是防弹的,首先,总是在你的代码库中 throw Error。记住这个规则很棒,但是用一个 ESLint 规则(例如 https://typescript-eslint.io/rules/no-throw-literal/)强制执行它更好!

更重要的是:在使用捕获的错误之前,确保它实际上是一个 Error。为此,请确保在 tsconfig.json 中启用了 useUnknownInCatchVariables 标志(请注意,如果处于 strict 模式下,则默认启用)。这将使 catch 块中的错误变量 unknown 而不是 any

try {
  runFragileOperation()
} catch (err) { // err is `unknown`
  console.log(err.message) // this will fail because we're not checking the `err` type
}

这是一个很大的进步:我们可以在确保其类型之前不滥用错误。默认情况下,它不实用:

try {
  runFragileOperation()
} catch (err) {
  if (err instanceof Error) console.log(err.message)

  // what do you do if err is not an `Error`?
}

catch 块中不断使用 if 块检查错误类型很麻烦。此外,如果不是 Error,你该怎么办?

我们在 Orus 团队中提出的解决方案是拥有一个 ensureError 函数,它确保一个错误是一个 Error。如果不是,则将抛出的值包装在 Error 中。实现如下:

function ensureError(value: unknown): Error {
  if (value instanceof Error) return value

  let stringified = '[Unable to stringify the thrown value]'
  try {
    stringified = JSON.stringify(value)
  } catch {}

  const error = new Error(`This value was thrown as is, not through an Error: ${stringified}`)
  return error
}

使用这个函数,操作捕获的错误更加简单:

try {
  runFragileOperation()
} catch (err) {
  const error = ensureError(err)

  console.log(error.message)
}

这样做有几个好处:

  • 它处理可能被抛出的任何东西,只要值是 JSON 可串行化的,就不会丢失任何信息
  • 它如果需要创建一个 Error 实例,尽早添加堆栈跟踪。因此,错误可以轻松传播,并具有最相关的堆栈跟踪
  • 使用看起来更加清洁:用一行简短的代码,你确信正在操作一个 Error。因为这必须在每个 catch 块中使用,所以这相当重要

这个简单的函数显著简化了我们的错误处理代码。

戒律2:不要丢失堆栈跟踪

你能在这个片段中发现问题吗?

try {
  runFragileOperation()
} catch (err) {
  const error = ensureError(err)

  throw new Error(`Running fragile operation failed: ${error.message}`)
}

好吧,标题很早就透露了:我们失去了初始错误的堆栈跟踪。

这似乎不是很重要,但尽可能多地拥有信息可以在调试问题时极大地帮助。

在旧的 JavaScript 中保留堆栈跟踪(“链接”错误)相当复杂,但你很幸运:从 Node.js 16.9.0 开始,并且自 2021 年中期以来在大多数浏览器中可用,cause 属性允许你以向后兼容的方式将“原始”错误附加到 Error 上:

const error1 = new Error("Network error")
const error2 = new Error("The update failed", { cause: error1 })

console.log(error2)

/* Prints:

Error: The update failed
    at REPL2:1:16
    at Script.runInThisContext (node:vm:129:12)
    ... 7 lines matching cause stack trace ...
    at [_line] [as _line] (node:internal/readline/interface:892:18) {
  [cause]: Error: Network error
      at REPL1:1:16
      at Script.runInThisContext (node:vm:129:12)
      at REPLServer.defaultEval (node:repl:572:29)
      at bound (node:domain:433:15)
      at REPLServer.runBound [as eval] (node:domain:444:12)
      at REPLServer.onLine (node:repl:902:10)
      at REPLServer.emit (node:events:525:35)
      at REPLServer.emit (node:domain:489:12)
      at [_onLine] [as _onLine] (node:internal/readline/interface:422:12)
      at [_line] [as _line] (node:internal/readline/interface:892:18)
*/

正如所示,原始错误完全保留,包括其堆栈跟踪。请注意,它不仅限于一个错误,错误原因可能有一个原因,该原因可能有一个原因,该原因可能有一个原因……你懂的!

如果你想知道为什么我们抛出了一个 new Error 而不是只重新抛出原始错误,请看下面的例子:

try {
  runFragileOperation()
} catch (err) {
  const error = ensureError(err)

  if (!config.fallbackEnabled) throw error

  try {
    runFallback()
  } catch {
    // for the sake of this example, we discard the `runFallback`
    // stack trace on purpose, but in course on production we shouldn't!
    throw error
  }
}

与抛出新错误相比,我们失去了一些好处:

  • 错误消息可能不太清晰。你可能更喜欢你的函数以一个漂亮的 Calling API failed 失败,而不是一个更模糊的 read ECONNRESET
  • 我们不知道哪个 throw 会抛出。是来自回退还是另一个的?结果的堆栈跟踪看起来完全相同,如果我们抛出了新的错误,情况将不同

戒律3:使用常量错误消息

大多数监控和错误跟踪平台分析错误消息以确定错误是否频繁。看下面的例子:

try {
  await logRequest(requestId, { elapsedTime })
} catch (err) {
  const error = ensureError(err)

  throw new Error(`Could not log request with ID "${requestId}"`, { cause: error })
}

想象一下,logRequest 将请求记录在数据库中,但数据库失败了。如果由于此原因有 1,000 个请求失败,将有 1,000 个不同的错误消息:

Could not log request with ID "9r7S8_ZoobNwRKafaVeP7"
Could not log request with ID "v2zGvKj-JVdFg_vjJyUP1"
Could not log request with ID "CaU_eS8olPcbbxfIPiUWN"
[...]

这将是有问题的,因为:

  • 如果你是一个小公司,你的监控平台可能会在有任何单个意外错误时警报你。你将最终拥有 1,000 个警报
  • 如果你是一家大公司,并且你的监控平台在达到一定发生阈值时警报你,每个错误消息都是唯一的,你将永远不会收到通知解决方法是确保你的错误信息是固定的。变量数据应该作为错误上下文(或元数据或任何你喜欢的名称)的一部分设置。出于类型安全的考虑,你需要创建自己的Error子类:
type Jsonable = string | number | boolean | null | undefined | readonly Jsonable[] | { readonly [key: string]: Jsonable } | { toJSON(): Jsonable }

export class BaseError extends Error {
  public readonly context?: Jsonable

  constructor(message: string, options: { error?: Error, context?: Jsonable } = {}) {
    const { cause, context } = options

    super(message, { cause })
    this.name = this.constructor.name

    this.context = context
  }
}

现在,你可以将上下文附加到错误上,同时确保你的错误可序列化为JSON,这要归功于Jsonable类型。上面的错误现在将如下所示:

throw new BaseError('Could not log request', { cause: error, context: { requestId } })

错误分组将按预期进行,错误消息始终相同!对于调试,你仍然可以访问上下文中的相关数据。

第四诫:提供适当的上下文

这是一个小问题,但仍然非常重要:将相关数据附加到你的错误上下文中。提供太少或太多的上下文都会使你更难调试问题。看下面的例子:

try {
  await billCustomer(customer.id, quote.amount)
} catch (err) {
  const error = ensureError(err)

  throw new BaseError('Could not bill customer', {
    cause: error,
    context: { customer, quote }
  })
}

这里可能提供了太多的上下文:有可能,要调试这个问题,你只需要customer.idquote.amount。在这种情况下,调试问题需要你浏览整个上下文,而这可能是庞大的(例如,报价可能有很多信息)。此外,如果customer中有敏感数据怎么办?将客户的个人信息放在你的错误监控平台中可能不是你想要的。

在这种情况下,提供customerIdquoteAmount就足够了。

第五诫:不要为预期会发生的问题抛出错误

由于TypeScript的性质,抛出错误应该被视为最后的手段,即难以或不可能从中恢复。

实际上,与其他语言不同,无法保证在TypeScript中抛出的错误将被处理。这是因为抛出的错误类型不是函数签名的一部分。

那么我们怎样才能保证错误被处理呢?嗯,错误应该是函数签名的一部分。你可以通过借鉴来自Rust的Result模式来实现这一点,这是一个简单但相当强大的模式:

type Result<T, E extends BaseError = BaseError> = { success: true, result: T } | { success: false, error: E }

有了这个类型,你现在可以表达函数可能失败的事实。例如,一个访问API服务器的函数将如下所示:

type ApiResponse = { data: string }

export async function fetchDataFromApi(): Result<ApiResponse> {
  try {
    const result = await fetch('https://api.local')

    // for the sake of this snippet we don't do it
    // but we should validate `result` matches the expected format

    return { success: true, result}
  } catch (err) {
    const error = ensureError(err)
    
    return { success: false, error }
  }
}

现在,在确保成功之前无法访问API中的数据。同时,注意到这个函数永远不会抛出异常。

上面的例子有点粗糙和冗长,但这是为了演示的最大化简化。实际上,有很好的库提供了优秀的API,以有效而优雅地使用Result,但这超出了本文的范围。

简而言之,为了确保你的用户有最好的体验,请问自己一个问题:“这个函数可能失败吗?”如果是,使用Result模式可能是一个好主意,因为你有保证失败情况将被处理,这样你的用户将看到一个漂亮的消息或一个优雅的回退,而不是一个崩溃。

版权声明:本文内容由TeHub注册用户自发贡献,版权归原作者所有,TeHub社区不拥有其著作权,亦不承担相应法律责任。 如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

点赞(0)
收藏(0)
一个人玩
先找到想要的,然后出发

评论(0)

添加评论