错误处理是软件工程重要的一部分。如果处理得当,它可以为你节省数小时的调试和故障排除时间。我发现了与错误处理相关的三大疑难杂症:
- TypeScript 的错误类型
- 变量范围
- 嵌套
让我们逐一深入了解它们带来的挠头问题。
疑难杂症一:Typescript 错误类型
在 JavaScript 中最常见的错误处理方式与大多数编程语言相同:
js
try {
throw new Error('oh no!')
} catch (error) {
console.dir(error)
}
最终会抛出这样一个对象:
js
{
message: 'oh no!'
stack: 'Error: oh no!\n at <anonymous>:2:8'
}
这看起来非常简单明了,那么 Typescript 又是怎样的呢? 首先你能看到的是在 Typescript 中使用 try/catch
并检查错误类型是,得到的是 unknow
。 对于刚接触 Typescript 的人来说遇到这种问题是非常挠头的。解决这一问题的常用方法是简单地将错误转为其他类型,如下所示:
js
try {
throw new Error('oh no!')
} catch (error) {
console.log((error as Error).message)
}
这种方法可能适用于 99.9% 的捕获错误。但为什么 TypeScript 的错误处理看起来很麻烦呢?原因在于无法推断出 "error" 的类型,因为 try/catch
并不只捕获错误,它还捕获任何抛出的错误。在 JavaScript(和 TypeScript)中,几乎可以抛出任何东西,如下所示:
js
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 是最具破坏性的情况,因为它们可能导致应用程序崩溃,但其他值也可能被抛出。例如:
js
try {
throw false
} catch (error) {
console.log((error as Error).message)
}
这里的主要区别在于,它不会抛出 TypeError
,而是直接返回 undefined
。虽然这不会直接导致应用程序崩溃,因此破坏性较小,但也会带来其他问题,例如在日志中显示未定义。此外,根据使用undefined
值的方式,它还可能间接导致应用程序崩溃。请看下面的示例:
js
try {
throw false
} catch (error) {
console.log((error as Error).message.trim())
}
在这里,调用 undefined
上的 .trim()
将触发 TypeError
,可能导致应用程序崩溃。
从本质上讲,TypeScript 的目的是通过将 catchables
的类型指定为 unknow
来保护我们。这种方法让开发人员有责任确定抛出值的正确类型,有助于防止出现运行时问题。
如下所示,您可以使用可选的链式操作符 (?.) 来保护您的代码:
js
try {
throw undefined
} catch (error) {
console.log((error as Error)?.message?.trim?.())
}
虽然这种方法可以保护你的代码,但它使用了两个会使代码维护复杂化的 TypeScript 特性:
- 类型转换破坏了 TypeScript 的保障措施,即确保变量遵循其指定的类型。
- 在非可选类型上使用可选的链式操作符,在类型不匹配的情况下,如果有人遗漏了这些操作符,也不会引发任何错误。
更好的方法是利用 TypeScript 的类型保护。类型保护本质上是一种函数,它能确保特定值与给定类型相匹配,并确认可以安全地按预期使用。下面是一个类型保护的示例,用于验证捕获的变量是否属于 Error
类型:
js
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'
这种类型防护简单明了。它首先确保值不是假的,这意味着它不会是 undefined
或 null
。然后,它会检查它是否是一个具有预期属性的对象。
这种类型保护可以在代码的任何地方重复使用,以验证对象是否是 Error
。下面是一个应用示例:
js
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。请看下面这个例子:
js
try {
const fileContent = fs.readFileSync(filePath, 'utf8')
} catch {
console.error(`Unable to load file`)
return
}
console.log(fileContent)
在本例中,由于 fileContent
是在 try 代码块内定义的,因此在该代码块外无法访问。为了解决这个问题,你可能会想在 try
代码块之外定义变量:
js
let fileContent
try {
fileContent = fs.readFileSync(filePath, 'utf8')
} catch {
console.error(`Unable to load file`)
return
}
console.log(fileContent)
这种方法并不理想。使用 let
而不是 const
,就意味着变量是可变的,这会带来潜在的错误。此外,它还会增加代码的阅读难度。
规避这一问题的方法之一是将 try/catch
代码块封装在一个函数中:
js
const fileContent = (() => {
try {
return fs.readFileSync(filePath, 'utf8')
} catch {
console.error(`Unable to load file`)
return
}
})()
if (!fileContent) {
return
}
console.log(fileContent)
虽然这种方法解决了可变性问题,但却使代码变得更加复杂。我们可以通过创建自己的可重用封装函数来解决这个问题。
疑难杂症三:嵌套
下面的示例演示了如何在可能出现多个错误的情况下使用新的 logError
函数:
js
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
虽然错误提供的细节会使代码更容易调试,但其有限的可读性会给代码维护带来挑战。try/catch 块引起的嵌套增加了阅读函数时的认知负担。不过,有一种方法可以简化代码,如下所示:
js
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 的功能,我们可以毫不费力地为此制作一个封装函数。
第一步是确定希望如何规范化错误。下面是一种方法:
ts
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
的抛出值转换为标准化错误:
js
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
对象,我们还需要一个类型保护函数:
js
export const isNormalizedError = (value: unknown): value is NormalizedError =>
isError(value) && 'originalValue' in value && value.stack !== undefined
现在,我们需要设计一个函数,帮助我们避免使用 try/catch
。另一个需要考虑的关键问题是错误的发生,它可以是同步的,也可以是异步的。理想情况下,我们需要一个能同时处理这两种情况的函数。首先,让我们创建一个类型保护来识别 Promise
:
ts
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
函数了:
ts
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
封装在自己的匿名自调用函数中,这样代码的可读性就大大提高了。
下面是一个重构版本:
ts
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}`
)
}
}
这样就解决了所有的疑难杂症:
- 类型现在可以安全使用,因此不再需要
logError
,可以直接使用console.log
来记录错误。 - 使用
noThrow
可以控制范围,在定义const fetchData
时就证明了这一点,以前必须使用let fetchData
。 - 嵌套已减少到单层,使代码更易于维护。
你可能还注意到,我们在 fetch
时没有使用 noThrow
。相反,使用了 toNormalizedError
,其效果与 noThrow
差不多,但嵌套更少。由于我们构建 noThrow
函数的方式,你可以在获取时使用它,就像我们在同步函数中使用它一样:
js
const fetchDataResponse = await noThrow(() =>
fetch('https://api.example.com/fetchData')
)
总结
在不断变化的软件开发环境中,错误处理仍然是稳健应用程序设计的基石。正如我们在本文中所探讨的,try/catch
等传统方法虽然有效,但有时会导致代码结构复杂,尤其是在结合 JavaScript 和 TypeScript 的动态特性时。通过使用 TypeScript 的功能,展示了一种精简的错误处理方法,它不仅简化了我们的代码,还增强了代码的可读性和可维护性。
NormalizedError
类和 noThrow
实用功能的引入展示了现代编程范式的强大功能。这些工具允许开发人员从容地处理同步和异步错误,确保应用程序在面对突发问题时仍能保持弹性。