TypeScript 系列:更优雅的异步错误处理,认识 Discriminated Unions 的强大之处

82 阅读4分钟

问题

在做异步错误处理封装函数的时候,如下:

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)

image.png

解决办法使用 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 历史:

鉴于此,我们将实现一个类型更安全的 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”。

总结

  • 可读性
  • 类型更精确
  • 穷尽性检查

更多有关 discriminated 的使用场景