Throwable - 在 TypeScript 中类型安全地处理 Error

August 22, 2021 · 4 min read

在 ts/js 中我们一般通过 throw, try..catch 来处理 error, 但是这种方式无法保证类型安全: 一个 function 无法告诉使用者它可能出现的必须要被处理的问题。这很大程度限制了 lib 开发者的表达能力:因为没处理的 throw 可能会导致应用崩溃,所以在出现无法处理的情况时直接 return undefined 可能是更好的选择。

但是我们可以借鉴 Haskell 和 Rust 当中处理异常的方式。这两门语言都没有提供 try…catch 的方法,而是通过一种特殊的函数返回类型来表达异常。在 Haskell 中用了一种数据结构:Either,它在 Rust 中被称为 Result。例如下面除法的例子,函数的返回值是 Result 类型,而不直接是数字。

fn div(x: u32, y: u32) -> Result<u32, &str> { 
  if y == 0 {
    return Err("divZero");
  }

  return Ok(x / y);
}

Result 类型不像 throw 出的 Error 会自动向上冒泡,而是将这个冒泡的流程交给开发者进行。Rust 提供了很多 Utils 函数 来方便上层的程序对其进行处理。

有的小伙伴可能会问,T | undefined 或者 T | Error 好像也能解决问题?确实能解决类型标注上的问题,但是这样使用起来笨拙很多,会有很多重复性的代码。

Throwable

我借鉴 Rust 和 Haskell 实现了 Throwable。在概念上 Throwable 和 Rust 的 Result 是一样的, 改称为 Throwable 主要是考虑理解成本。

使用方式

import {Ok, Err, Throwable} from '@typ3/throwable'
// in deno
import {Ok, Err, Throwable} from 'https://deno.land/x/throwable@v0'

function parse(input: string): Throwable<string[], 'invalid'> {
  const ans = []
  if (!input.startsWith('{')) {
    // Rather than `throw new Error()`
    return Err('invalid');
  }

  ...

  return Ok(ans);
}

使用对比

对于 Throw 的方案来说它有什么更方便的地方呢?假设我们有以下需求

  • 给一个 JSON 文件路径列表,读取解析并返回其中 name 字段的值
  • 如果解析错误则跳过
  • 如果文件不存在则尝试增加后缀 .bk
  • 如果出现文件不存在 / 解析错误之外的错误,则需要将 Error 抛出

传统方式的实现

class NotExistsError extends Error{...}
class ParseError extends Error{...}

function readJsonFile<T = Object>(path: string): T{
  ...
}

async function readName(path: string, shouldRetry=true): string | undefined{
  try {
    return (await readJsonFile(path)).name;
  } catch (e) {
    if (e instanceof NotExistsError) {
      if (shouldRetry) {
        return readName(path + '.bk');
      }

      return;
    } else if (e instanceof ParseError) { 
      return;
    }

    throw e;
  }

  return;
}

function getValidNames(paths: string[]): Promise<string[]> {
  return Promise.all(paths.map(readName)).filter(x => x != null);
}

Throwable 的实现

type MThrowable = Throwable<T, 'notExists' | {type: 'parseError', msg: string} >;

function readJsonFile<T = Object>(path: string): Promise<MThrowable>{
  ...
}

async function readName(path: string): Promise<MThrowable>{
  let result = await readJsonFile(path);
  if (result.error === 'notExists') {
    result = await readJsonFile(path + '.bk');
  }

  return result.pipe(x => x.name);
}

function getValidNames(paths: string[]): Promise<string[]> {
  return Promise.all(paths.map(readName)).filter(x => x.isOk());
}

Throwable 项目地址


Profile picture

Written by  Zixuan Chen