问题
在做异步错误处理封装函数的时候,如下:
export async function box<T>(
promise: Promise<T>
): Promise<[Error | undefined, undefined | T]> {
try {
return [undefined, await promise];
} catch (error) {
return [
error as Error || new Error('fallback to empty error because promise rejected with no or falsy reason'),
undefined
];
}
}
代码解释:
- 返回值是个二元组,第一项是 error 第二项是返回值
- 如果成功则返回
[undefined, response] - 失败则返回
[Error, undefined] - 错误兜底
new Error('fallback to ...')是为了让报错时第一项一定有值(js 可以的 error 可以是任何值,包括空值),这样可以在第一项为空提前 return,让第二项的值能类型缩窄。 - 使用 undefined 而非 null 是为了可以赋予默认值。比如 const
[err, resp = {}] = box(...)
发现调用时有一个不符合预期的地方,明明 resp 是数字为何提示可能 undefined?
async function fetchFromServer() {
return 100
}
async function init() {
const [err, resp] = await box2(fetchFromServer())
if (err) {
return
}
resp.toFixed() // ❌ 'resp' is possibly 'undefined'.ts(18048)
}
对象可能为“未定义”。ts(2532)
解决办法使用 Discriminated Union:
即将 [Error | undefined, undefined | T] 拆成两个类型
[Error, undefined]|[undefined, T]
export async function box<T>(
promise: Promise<T>
- ): Promise<[Error | undefined, undefined | T]> {
+ ): Promise<[Error, undefined] | [undefined, T]> {
try {
return [undefined, await promise];
} catch (error) {
return [
error as Error || new Error('fallback to empty error because promise rejected with no or falsy reason'),
undefined
];
}
}
为了更好阅读可将类型单独拎出:
type IBoxed<T> = [Error, undefined] | [undefined, T]
也就是只会返回 [错误, undefined] 或 [undefined, 返回值],故前有 if (err) { return } 已经 return,那么 ts 就能识别出接下来的 resp 一定是有值的。
第二种写法:更安全的 box
上述写法有个假设前提,catch 的 error 一定是 Error 类型,但实际 JS 可以抛出任何类型,故使用 as Error 并不严谨(TypeScript 4.0 版本将其从 any 改成了更安全的 unknow)。
catch clause binding 历史:
- 4.0 之前默认 any
- 4.0 后仍然默认 any,但可指定类型,只能是 any 或 unknown 否则报错
Catch clause variable type annotation must be 'any' or 'unknown' if specified.(1196) - 4.4 后默认
unknown,类型可指定 any 或 unknown。也就是类型更安全了。
鉴于此,我们将实现一个类型更安全的 box 版本:
/**
* 更优雅的异步错误处理。
* 将异步请求封装成不会报错的函数。具备 TS 泛型。
* - 将嵌套的错误处理扁平化。
* - 错误优先。
* - 精确类型:通过 TS 泛型和 Tagged Union 或 [Discriminated Union](https://basarat.gitbook.io/typescript/type-system/discriminated-unions)。
* @example
* const result = await box(someAsyncFunc())
*
* if (result.isErr) {
* // handle error
* return
* }
*
* // handle resp
*/
export async function box<T>(promise: Promise<T>) {
try {
const result = await promise
return {
isErr: false,
isOk: true,
value: result,
error: undefined,
} as const satisfies IBoxed
} catch (error) {
// 1. error as Error 会有问题,因为在 js 中 error 可能是任意类型,比如字符串
// 2. 其次使用 Error 兜底是为了实现:若元组第一项为空,则说明没有抛错,result 类型可以推断为 T,但其实在 js 中 error 可以为空,此时兜底可能会掩盖真实错误。
// 故改成对象形式,这样就避开了 error 可能为空,无法用 error 来判断是否有抛错的问题。虽然损失了元祖返回的便捷 DX 体验,但是使用 key 更明确,最重要是更严谨了。
// 参考 https://www.npmjs.com/package/neverthrow#resultiserr-method 做了 DX 提升
// return [error || new Error('Nil error'), undefined] as const
return {
isErr: true,
isOk: false,
value: undefined,
error,
} as const satisfies IBoxed
}
}
type IBoxed = {
isErr: boolean
isOk: boolean
value: unknown
error: unknown
}
变化:
- 使用对象而非元祖,省去了错误兜底
- 命名对象比无名元祖更清晰
使用:
async function init() {
const { isErr, value: resp } = await box(fetchFromServer())
if (isErr) {
return
}
resp.toFixed() // resp 能精确推断为 number,因为 isErr 已经提前 return 了。
}
优雅在哪里?
- 将嵌套的错误处理扁平化。
- 错误优先。
- 精确类型:通过 TS 泛型叠加 Tagged Union 或 Discriminated Union。
什么是 Discriminated Union
当一个类型中存在一个字段,其值是字面量类型(字符串、数字、false/true 等,甚至某个位置的 缺省与否),可以用来区分类型,则称其为 Discriminated Unions,也叫做 Tagged unions(这种叫法更好理解一些)。
比如:
// 🚫 不建议
interface Person = {
type: "Employee" | "Visitor" | "Contractor";
name: string;
employeecode?: number;
visitorcode?: number;
contractorcode?: number;
}
如下定义多个类型才是最地道的写法(这样定义后对整个业务的理解是不是更清晰了?):
// ✅
type Person = Employee | Visitor | Contractor;
interface Employee {
type: "Employee";
employeecode: number;
name: string;
}
interface Visitor {
type: "Visitor";
visitorcode: number;
name: string;
}
interface Contractor {
type: "Contractor";
contractorcode: number;
name: string;
}
除了文本的用法精准推断类型,还有实现类似 Rust 的“穷尽性检查 Exhaustive Check”。
总结
- 可读性
- 类型更精确
- 穷尽性检查