likes
comments
collection
share

如何在Typescript中做错误处理

作者站长头像
站长
· 阅读数 8

错误处理是软件工程重要的一部分。如果处理得当,它可以为你节省数小时的调试和故障排除时间。我发现了与错误处理相关的三大疑难杂症:

  • TypeScript 的错误类型
  • 变量范围
  • 嵌套

让我们逐一深入了解它们带来的挠头问题。

疑难杂症一:Typescript 错误类型

在 JavaScript 中最常见的错误处理方式与大多数编程语言相同:

try {
  throw new Error('oh no!')
} catch (error) {
  console.dir(error)
}

最终会抛出这样一个对象:

{
  message: 'oh no!'
  stack: 'Error: oh no!\n at <anonymous>:2:8'
}

这看起来非常简单明了,那么 Typescript 又是怎样的呢? 首先你能看到的是在 Typescript 中使用 try/catch 并检查错误类型是,得到的是 unknow。 对于刚接触 Typescript 的人来说遇到这种问题是非常挠头的。解决这一问题的常用方法是简单地将错误转为其他类型,如下所示:

try {
  throw new Error('oh no!')
} catch (error) {
  console.log((error as Error).message)
}

这种方法可能适用于 99.9% 的捕获错误。但为什么 TypeScript 的错误处理看起来很麻烦呢?原因在于无法推断出 "error" 的类型,因为 try/catch 并不只捕获错误,它还捕获任何抛出的错误。在 JavaScript(和 TypeScript)中,几乎可以抛出任何东西,如下所示:

try {
  throw undefined
} catch (error) {
  console.log((error as Error).message)
}

执行这段代码将导致在 "catch "代码块中抛出新的错误,这就没有达到使用 try/catch 的目的:

Uncaught TypeError: Cannot read properties of undefined (reading 'message') at <anonymous>:4:20

问题产生的原因是 undefined 中不存在 message 属性,从而导致在 catch 代码块中出现 TypeError。在 JavaScript 中,只有两个值会导致这个问题:undefined 和 null。

现在可能有人会问,有人抛出 undefined 或 null 的可能性有多大。虽然这种情况可能很少发生,但如果真的发生了,就会在代码中引入意想不到的行为。此外,考虑到在 TypeScript 项目中通常会使用大量第三方包,如果其中一个包无意中抛出了一个不正确的值,也不足为奇。

这就是 TypeScript 将可抛类型设置为 unknow 的唯一原因吗?乍一看,这可能只是一个罕见的边缘情况,使用类型转换是一个比较靠谱的解决方式。然而,事情并非如此简单。虽然 undefined 和 null 是最具破坏性的情况,因为它们可能导致应用程序崩溃,但其他值也可能被抛出。例如:

try {
  throw false
} catch (error) {
  console.log((error as Error).message)
}

这里的主要区别在于,它不会抛出 TypeError,而是直接返回 undefined。虽然这不会直接导致应用程序崩溃,因此破坏性较小,但也会带来其他问题,例如在日志中显示未定义。此外,根据使用undefined 值的方式,它还可能间接导致应用程序崩溃。请看下面的示例:

try {
  throw false
} catch (error) {
  console.log((error as Error).message.trim())
}

在这里,调用 undefined 上的 .trim() 将触发 TypeError,可能导致应用程序崩溃。

从本质上讲,TypeScript 的目的是通过将 catchables 的类型指定为 unknow 来保护我们。这种方法让开发人员有责任确定抛出值的正确类型,有助于防止出现运行时问题。

如下所示,您可以使用可选的链式操作符 (?.) 来保护您的代码:

try {
  throw undefined
} catch (error) {
  console.log((error as Error)?.message?.trim?.())
}

虽然这种方法可以保护你的代码,但它使用了两个会使代码维护复杂化的 TypeScript 特性:

  • 类型转换破坏了 TypeScript 的保障措施,即确保变量遵循其指定的类型。
  • 在非可选类型上使用可选的链式操作符,在类型不匹配的情况下,如果有人遗漏了这些操作符,也不会引发任何错误。

更好的方法是利用 TypeScript 的类型保护。类型保护本质上是一种函数,它能确保特定值与给定类型相匹配,并确认可以安全地按预期使用。下面是一个类型保护的示例,用于验证捕获的变量是否属于 Error 类型:

export const isError = (value: unknown): value is Error =>
  !!value &&
  typeof value === 'object' &&
  'message' in value &&
  typeof value.message === 'string' &&
  'stack' in value &&
  typeof value.stack === 'string'

这种类型防护简单明了。它首先确保值不是假的,这意味着它不会是 undefinednull。然后,它会检查它是否是一个具有预期属性的对象。

这种类型保护可以在代码的任何地方重复使用,以验证对象是否是 Error。下面是一个应用示例:

const logError = (message: string, error: unknown): void => {
  if (isError(error)) {
    console.log(message, error.stack)
  } else {
    try {
      console.log(
        new Error(
          `Unexpected value thrown: ${
            typeof error === 'object' ? JSON.stringify(error) : String(error)
          }`
        ).stack
      )
    } catch {
      console.log(
        message,
        new Error(`Unexpected value thrown: non-stringifiable object`).stack
      )
    }
  }
}

try {
  const circularObject = { self: {} }
  circularObject.self = circularObject
  throw circularObject
} catch (error) {
  logError('Error while throwing a circular object:', error)
}

通过创建一个利用 isError 类型防护的 logError 函数,我们可以安全地记录标准错误以及任何其他抛出的值。这对于排除意外问题特别有用。不过,我们需要谨慎,因为 JSON.stringify 也会抛出错误。通过将其封装在自己的 try/catch 块中,可以为对象提供更详细的信息,而不仅仅是记录其字符串表示 [object Object]

此外,我们还可以检索新 Error 对象实例化之前的堆栈跟踪。这将包括抛出原始值的位置。虽然该方法不能直接提供抛出值的堆栈跟踪,但它提供了抛出后的跟踪,足以追溯到问题的源头。

疑难杂症二:变量范围

范围界定可能是错误处理中最常见的疑难杂症,适用于 JavaScript 和 TypeScript。请看下面这个例子:

try {
  const fileContent = fs.readFileSync(filePath, 'utf8')
} catch {
  console.error(`Unable to load file`)
  return
}

console.log(fileContent)

在本例中,由于 fileContent 是在 try 代码块内定义的,因此在该代码块外无法访问。为了解决这个问题,你可能会想在 try 代码块之外定义变量:

let fileContent

try {
  fileContent = fs.readFileSync(filePath, 'utf8')
} catch {
  console.error(`Unable to load file`)
  return
}

console.log(fileContent)

这种方法并不理想。使用 let 而不是 const,就意味着变量是可变的,这会带来潜在的错误。此外,它还会增加代码的阅读难度。

规避这一问题的方法之一是将 try/catch 代码块封装在一个函数中:

const fileContent = (() => {
  try {
    return fs.readFileSync(filePath, 'utf8')
  } catch {
    console.error(`Unable to load file`)
    return
  }
})()

if (!fileContent) {
  return
}

console.log(fileContent)

虽然这种方法解决了可变性问题,但却使代码变得更加复杂。我们可以通过创建自己的可重用封装函数来解决这个问题。

疑难杂症三:嵌套

下面的示例演示了如何在可能出现多个错误的情况下使用新的 logError 函数:

export const doStuff = async (): Promise<void> => {
  try {
    const fetchDataResponse = await fetch('https://api.example.com/fetchData')
    const fetchDataText = await fetchDataResponse.text()

    if (!fetchDataResponse.ok) {
      throw new Error(
        `Unexpected response while fetching data. Status: ${fetchDataResponse.status} | Status text: ${fetchDataResponse.statusText} | Body: ${fetchDataText}`
      )
    }

    let fetchData
    try {
      fetchData = JSON.parse(fetchDataText) as unknown
    } catch {
      throw new Error(`Failed to parse fetched data response as JSON: ${fetchDataText}`)
    }

    if (
      !fetchData ||
      typeof fetchData !== 'object' ||
      !('data' in fetchData) ||
      !fetchData.data
    ) {
      throw new Error(
        `Fetched data is not in the expected format. Body: ${fetchDataText}`
      )
    }

    const storeDataResponse = await fetch('https://api.example.com/storeData', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(fetchData),
    })

    const storeDataText = await storeDataResponse.text()

    if (!storeDataResponse.ok) {
      throw new Error(
        `Unexpected response while storing data. Status: ${storeDataResponse.status} | Status text: ${storeDataResponse.statusText} | Body: ${storeDataText}`
      )
    }
  } catch (error) {
    logError('An error occurred:', error)
  }
}

你会发现调用的是 .text() API,而不是 .json()。因为 fetch 能调用这两种方法中的一种。由于我们的目标是在 JSON 转换失败时显示正文内容,因此首先调用 .text(),然后手动还原为 JSON,确保在此过程中捕捉到任何错误。为避免出现以下隐含错误:

Uncaught SyntaxError: Expected property name or '}' in JSON at position 42

如何在Typescript中做错误处理

虽然错误提供的细节会使代码更容易调试,但其有限的可读性会给代码维护带来挑战。try/catch 块引起的嵌套增加了阅读函数时的认知负担。不过,有一种方法可以简化代码,如下所示:

export const doStuffV2 = async (): Promise<void> => {
  try {
    const fetchDataResponse = await fetch('https://api.example.com/fetchData')
    const fetchData = (await fetchDataResponse.json()) as unknown

    if (
      !fetchData ||
      typeof fetchData !== 'object' ||
      !('data' in fetchData) ||
      !fetchData.data
    ) {
      throw new Error('Fetched data is not in the expected format.')
    }

    const storeDataResponse = await fetch('https://api.example.com/storeData', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(fetchData),
    })

    if (!storeDataResponse.ok) {
      throw new Error(`Error storing data: ${storeDataResponse.statusText}`)
    }
  } catch (error) {
    logError('An error occurred:', error)
  }
}

这次重构解决了嵌套问题,但也带来了一个新问题:错误报告的粒度不够。通过删除检查,变得更加依赖错误信息本身来理解问题。正如我们从一些 JSON.parse 错误中看到的那样,这并不总能提供最好的颗粒度。

考虑到我们讨论的所有的疑难杂症,是否存在有效处理错误的最佳方法?

解决方案

应该寻求一种比传统的 try/catch 块更优越的错误处理方法。通过利用 TypeScript 的功能,我们可以毫不费力地为此制作一个封装函数。

第一步是确定希望如何规范化错误。下面是一种方法:

export class NormalizedError extends Error {
  
  stack: string = ''
  /** The original value that was thrown. */
  originalValue: unknown

  /**
   * Initializes a new instance of the `NormalizedError` class.
   *
   * @param error - An `Error` object.
   * @param originalValue - The original value that was thrown.
   */
  constructor(error: Error, originalValue?: unknown) {
    super(error.message)
    this.stack = error.stack ?? this.message
    this.originalValue = originalValue ?? error

   
    Object.setPrototypeOf(this, NormalizedError.prototype)
  }
}

扩展 Error 对象的主要优点是它的行为与标准错误类似。从头开始创建一个自定义错误对象可能会导致复杂问题,尤其是在使用 instanceof 操作符检查其类型时。这就是为什么要显式地设置原型,以确保 instanceof 能正确工作,尤其是当代码被移植到 ES5 时。

此外,Error 的所有原型函数在 NormalizedError 对象上都可用。构造函数的设计还简化了创建新 NormalizedError 对象的过程,因为它要求第一个参数必须是一个实际的 Error。以下是 NormalizedError 的优点:

  • 由于构造函数要求第一个参数必须是 Error,因此它始终是一个有效的错误。
  • 添加了一个新属性 originalValue。这可以检索抛出的原始值,这对于从错误中提取附加信息或在调试过程中非常有用。
  • 堆栈永远不会是未定义的。在许多情况下,记录堆栈属性比记录消息属性更有用,因为它包含更多信息。然而,TypeScript 将其类型定义为 string | undefined,这主要是出于跨环境兼容性的考虑(在传统环境中经常出现)。通过重写类型并保证其始终为字符串,可以简化其使用。

既然已经定义了标准化错误的表示方法,就需要一个函数将 unknow 的抛出值转换为标准化错误:

export const toNormalizedError = <E>(
  value: E extends NormalizedError ? never : E
): NormalizedError => {
  if (isError(value)) {
    return new NormalizedError(value)
  } else {
    try {
      return new NormalizedError(
        new Error(
          `Unexpected value thrown: ${
            typeof value === 'object' ? JSON.stringify(value) : String(value)
          }`
        ),
        value
      )
    } catch {
      return new NormalizedError(
        new Error(`Unexpected value thrown: non-stringifiable object`),
        value
      )
    }
  }
}

使用这种方法,不再需要处理 unknow 类型的错误。所有错误都将是合适的 Error 对象,从而为我们提供尽可能多的信息,并消除出现意外错误值的风险。

为了安全地使用 NormalizedError 对象,我们还需要一个类型保护函数:

export const isNormalizedError = (value: unknown): value is NormalizedError =>
  isError(value) && 'originalValue' in value && value.stack !== undefined

现在,我们需要设计一个函数,帮助我们避免使用 try/catch 。另一个需要考虑的关键问题是错误的发生,它可以是同步的,也可以是异步的。理想情况下,我们需要一个能同时处理这两种情况的函数。首先,让我们创建一个类型保护来识别 Promise

export const isPromise = (result: unknown): result is Promise<unknown> =>
  !!result &&
  typeof result === 'object' &&
  'then' in result &&
  typeof result.then === 'function' &&
  'catch' in result &&
  typeof result.catch === 'function'

有了安全识别 Promise 的能力,就可以继续实现新的 noThrow 函数了:

type NoThrowResult<A> = A extends Promise<infer U>
  ? Promise<U | NormalizedError>
  : A | NormalizedError


export const noThrow = <A>(action: () => A): NoThrowResult<A> => {
  try {
    const result = action()
    if (isPromise(result)) {
      return result.catch(toNormalizedError) as NoThrowResult<A>
    }
    return result as NoThrowResult<A>
  } catch (error) {
    return toNormalizedError(error) as NoThrowResult<A>
  }
}

通过利用 TypeScript 的功能,我们可以动态支持异步和同步函数调用,同时保持准确的类型。这样,我们就可以使用单个实用程序函数来管理所有错误。

此外,如前所述,这对解决范围问题特别有用。可以简单地使用 noThrow,而不用将 try/catch 封装在自己的匿名自调用函数中,这样代码的可读性就大大提高了。

下面是一个重构版本:

export const doStuffV3 = async (): Promise<void> => {
  const fetchDataResponse = await fetch('https://api.example.com/fetchData').catch(toNormalizedError)

  if (isNormalizedError(fetchDataResponse)) {
    return console.log('Error fetching data:', fetchDataResponse.stack)
  }

  const fetchDataText = await fetchDataResponse.text()

  if (!fetchDataResponse.ok) {
    return console.log(
      `Unexpected response while fetching data. Status: ${fetchDataResponse.status} | Status text: ${fetchDataResponse.statusText} | Body: ${fetchDataText}`
    )
  }

  const fetchData = noThrow(() => JSON.parse(fetchDataText) as unknown)

  if (isNormalizedError(fetchData)) {
    return console.log(
      `Failed to parse fetched data response as JSON: ${fetchDataText}`,
      fetchData.stack
    )
  }

  if (
    !fetchData ||
    typeof fetchData !== 'object' ||
    !('data' in fetchData) ||
    !fetchData.data
  ) {
    return console.log(
      `Fetched data is not in the expected format. Body: ${fetchDataText}`,
      toNormalizedError(new Error('Invalid data format')).stack
    )
  }

  const storeDataResponse = await fetch('https://api.example.com/storeData', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(fetchData),
    }).catch(toNormalizedError)

  if (isNormalizedError(storeDataResponse)) {
    return console.log('Error storing data:', storeDataResponse.stack)
  }

  const storeDataText = await storeDataResponse.text()

  if (!storeDataResponse.ok) {
    return console.log(
      `Unexpected response while storing data. Status: ${storeDataResponse.status} | Status text: ${storeDataResponse.statusText} | Body: ${storeDataText}`
    )
  }
}

这样就解决了所有的疑难杂症:

  1. 类型现在可以安全使用,因此不再需要 logError,可以直接使用 console.log 来记录错误。
  2. 使用 noThrow 可以控制范围,在定义 const fetchData 时就证明了这一点,以前必须使用 let fetchData
  3. 嵌套已减少到单层,使代码更易于维护。

你可能还注意到,我们在 fetch 时没有使用 noThrow。相反,使用了 toNormalizedError,其效果与 noThrow 差不多,但嵌套更少。由于我们构建 noThrow 函数的方式,你可以在获取时使用它,就像我们在同步函数中使用它一样:

const fetchDataResponse = await noThrow(() =>
    fetch('https://api.example.com/fetchData')
  )

总结

在不断变化的软件开发环境中,错误处理仍然是稳健应用程序设计的基石。正如我们在本文中所探讨的,try/catch 等传统方法虽然有效,但有时会导致代码结构复杂,尤其是在结合 JavaScript 和 TypeScript 的动态特性时。通过使用 TypeScript 的功能,展示了一种精简的错误处理方法,它不仅简化了我们的代码,还增强了代码的可读性和可维护性。

NormalizedError 类和 noThrow 实用功能的引入展示了现代编程范式的强大功能。这些工具允许开发人员从容地处理同步和异步错误,确保应用程序在面对突发问题时仍能保持弹性。