首页

Published

- 6 min read

TypeScript实现优雅错误处理

img of TypeScript实现优雅错误处理

前言

众所周知,JavaScript采用 try {} catch {} 语句捕获错误,然而,在需要细粒度处理错误的场景下,try {} catch {} 语句会使代码结构变得异常臃肿。

考虑如下场景,在一个包含多个流程调用处理函数中,我们需要捕获函数的运行错误,并针对不同流程的失败进行不同的处理,如果使用 try {} catch {} 语句进行错误捕获,可能的代码实现如下:

   async function writeFile(path: string, content: string) {
	let fd: FileHandle

	try {
		fd = await fs.promises.open(path, 'w')
	} catch {
		throw new Error(`open file failed`)
	}

	try {
		await fd.write(content)
	} catch {
		throw new Error(`write file failed`)
	} finally {
		await fd.close()
	}
}

以上代码中,每处理一个错误场景都需要由一个 try {} catch {} 语句包裹,代码结构极为臃肿。

Errors are values

熟悉Go语言的朋友都知道,Go语言对错误处理的设计理念是「Errors are values」,即把错误当成普通值对待,错误与其他值地位相等,并没有特殊之处。 因此,Go语言中并没有其他编程语言中常见的 try {} catch {} 语句,而是通过一个通俗的约定实现错误处理:函数支持返回多个值,错误作为最后一个值返回,调用方通过条件判断语句进行错误处理。 笔者认为,Go语言的错误处理方式是细粒度的错误处理,这种方式强制开发者对每一个函数调用的错误进行处理(这也是Go语言错误处理被众多开发者诟病的一个原因),与之相反,传统的 try {} catch {} 语句则是粗粒度的处理方式,开发者只需把代码逻辑包裹在 try {} 语句中,即可在一个 catch {} 块中对错误进行统一处理。

针对上述的例子,我们所需要的恰恰就是细粒度的错误处理,如果用Go语言的错误处理方式重构上述代码案例,可能的代码实现如下:

   async function writeFile(path: string, content: string) {
	const [fd, openFailError] = await openFileHandle(path, 'w')

	if (openFailError) {
		throw new Error(`open file failed`)
	}

	const [_, writeFailError] = await writeFile(fd, content)
	await closeFileHandle(fd)

	if (writeFailError) {
		throw new Error(`write file failed`)
	}
}

重构后的代码由14行减少为10行(不计空行),没有了臃肿的 try {} catch {} 语句,代码更加清爽。

包装器

针对上述需求,可以包装一个高阶函数,用于捕获函数运行错误并以类似Go语言多返回值的形式返回元组,预期实现目标:

  • 支持同步/异步函数包装
  • 类型推断友好

代码实现

   export type Promisify<T> = T extends Promise<any> ? T : Promise<T>
export type UnPromisify<T> = T extends Promise<infer U> ? U : T

export type CatchItResult<T extends (...args: unknown[]) => unknown, E> =
	| [UnPromisify<ReturnType<T>>, null]
	| [null, NonNullable<E>]

export type CatchIt<T extends (...args: unknown[]) => unknown, E = never> = (
	...args: Parameters<T>
) => ReturnType<T> extends Promise<any> ? Promise<CatchItResult<T, E>> : CatchItResult<T, E>

const isPromise = <T>(o: unknown): o is Promise<T> => {
	return o instanceof Promise
}

export const catchIt = <T extends (...args: any[]) => any, E = Error>(fn: T) => {
	return ((...args: Parameters<T>) => {
		try {
			const result = fn(...args)

			return isPromise<UnPromisify<ReturnType<T>>>(result)
				? result.then((result) => [result, null]).catch((e) => [null, e])
				: [result, null]
		} catch (e) {
			return [null, e]
		}
	}) as CatchIt<T, E>
}

使用方式

catchIt(fn) 返回包装过的 fn() 函数,调用包装过的函数可以使用条件判断的形式处理错误。

   const f1 = catchIt((a: string, b: number) => b)
// 同步函数调用,类型推导正确
// 参数类型为[string, number],推导正确
const [r1, e1] = f1('', 0)
if (e1) {
	// 捕获到错误时,返回值为null,类型正确
	const r: null = r1
} else {
	// 未捕获到错误时,返回值为number,类型正确
	const r: number = r1
}

const f2 = catchIt(() => '')
// 无参数调用,类型推导正确
const [r2, e2] = f2()
if (e2) {
	const r: null = r2
} else {
	const r: string = r2
}

const f3 = catchIt(async (a: boolean) => a)
// 异步函数调用,类型推导正确
const [r3, e3] = await f3(true)
if (e3) {
	const r: null = r3
} else {
	const r: boolean = r3
}

结语

本文介绍了在需要细粒度进行错误处理场景下的一种解决方案,引入了一个包装器函数用于实现仿Go语言的错误处理机制。

笔者无意引起语言优劣之争,2种错误处理方式各有适用的场景,可以相互结合使用。