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());
}