关于在Typescript中做错误处理的方式详解
作者:托儿所夜十三
错误处理是软件工程重要的一部分。如果处理得当,它可以为你节省数小时的调试和故障排除时间。我发现了与错误处理相关的三大疑难杂症:
- 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'
这种类型防护简单明了。它首先确保值不是假的,这意味着它不会是 undefined
或 null
。然后,它会检查它是否是一个具有预期属性的对象。
这种类型保护可以在代码的任何地方重复使用,以验证对象是否是 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
虽然错误提供的细节会使代码更容易调试,但其有限的可读性会给代码维护带来挑战。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}` ) } }
这样就解决了所有的疑难杂症:
- 类型现在可以安全使用,因此不再需要
logError
,可以直接使用console.log
来记录错误。 - 使用
noThrow
可以控制范围,在定义const fetchData
时就证明了这一点,以前必须使用let fetchData
。 - 嵌套已减少到单层,使代码更易于维护。
你可能还注意到,我们在 fetch
时没有使用 noThrow
。相反,使用了 toNormalizedError
,其效果与 noThrow
差不多,但嵌套更少。由于我们构建 noThrow
函数的方式,你可以在获取时使用它,就像我们在同步函数中使用它一样:
const fetchDataResponse = await noThrow(() => fetch('https://api.example.com/fetchData') )
总结
在不断变化的软件开发环境中,错误处理仍然是稳健应用程序设计的基石。正如我们在本文中所探讨的,try/catch
等传统方法虽然有效,但有时会导致代码结构复杂,尤其是在结合 JavaScript 和 TypeScript 的动态特性时。通过使用 TypeScript 的功能,展示了一种精简的错误处理方法,它不仅简化了我们的代码,还增强了代码的可读性和可维护性。
NormalizedError
类和 noThrow
实用功能的引入展示了现代编程范式的强大功能。这些工具允许开发人员从容地处理同步和异步错误,确保应用程序在面对突发问题时仍能保持弹性。
以上就是关于在Typescript中做错误处理的方案详解的详细内容,更多关于Typescript错误处理的资料请关注脚本之家其它相关文章!