解决异步编程的痛点:to错误边界方案(await-to-js诞生原因)
to() 错误边界方案 · 学习文档
别名:Go 风格错误处理 / await-to-js / Result 模式
适用阶段:阶段 3.2(错误捕获)的进阶补充
一、问题起源:为什么需要它
1. 三代错误处理的演进
// ① 回调时代:error-first(Node 约定)
fs.readFile('./a.txt', (err, data) => {
if (err) return handle(err)
use(data)
})
// ② Promise 时代:.catch
fetchList().then(use).catch(handle)
// ③ async/await 时代:try/catch
try {
const data = await fetchList()
use(data)
} catch (e) {
handle(e)
}
2. try/catch 在多步 await 下的三个痛点
async function loadPage(id) {
try {
const user = await getUser(id) // ①
const orders = await getOrders(user.id) // ②
const detail = await getDetail(orders) // ③
render(detail)
} catch (e) {
message.error('加载失败') // 😖 三个问题:
}
}
| 痛点 | 说明 |
|---|---|
| ① 分不清是谁错 | ①②③ 的异常都进同一个 catch,无法区分来源 |
| ② 无法部分降级 | ①失败就直接短路,②③ 不执行;想"订单拉不到就显示空"做不到 |
| ③ 缩进与割裂 | 每层套 try 就缩进爆炸;或者用 let 在外层声明变量再赋值,代码被扯散 |
// 痛点 ③ 的真实写法:变量被提到外层,逻辑被切断
let user, orders
try { user = await getUser(id) } catch (e) { user = null }
try { orders = await getOrders(user?.id) } catch (e) { orders = [] }
二、to() 是什么
把 Promise 的"抛异常"转换成"返回值":[error, data] 二元组(Go 语言 v, err := f() 的思路)。
// 实现(TS 版)
export type SafeResult<T> = [Error, null] | [null, T]
export function to<T>(promise: Promise<T>): Promise<SafeResult<T>> {
return promise
.then((data): SafeResult<T> => [null, data])
.catch((err): SafeResult<T> => [err instanceof Error ? err : new Error(String(err)), null])
}
用法:不抛错了,改成让你"必须看见"错误。
const [err, user] = await to(getUser(id))
if (err) {
// 处理 / 降级 / 返回
return
}
// 这里 user 一定有值,且没有缩进
三、用法示例
基础:单步
const [err, res] = await to(findPage(params))
if (err) return message.error('查询失败')
tableData.value = res.data.records
多步:每步可独立降级 ⭐
async function loadPage(id: string) {
const [uErr, user] = await to(getUser(id))
if (uErr) return message.error('用户信息加载失败') // 致命错误:中止
const [oErr, orders] = await to(getOrders(user.id))
const list = oErr ? [] : orders // 非致命:降级为空
const [dErr, detail] = await to(getDetail(list))
if (dErr) return message.error('详情加载失败')
render({ user, list, detail })
}
- 没有一层缩进
- 每个错误都能单独决定:中止 / 降级 / 忽略
并行:配合 Promise.all
const [err, [user, orders]] = await to(Promise.all([getUser(id), getOrders(id)]))
if (err) { /* 任一失败都会到这里 */ }
// 或者逐个兜底(永不失败)
const [u, o] = await Promise.all([
to(getUser(id)),
to(getOrders(id)),
])
const user = u[1] ?? defaultUser
const orders = o[1] ?? []
四、四种方案横向对比
| 方案 | 代码形态 | 能区分错误来源 | 能部分降级 | 缩进 | 适用场景 |
|---|---|---|---|---|---|
| 一个大 try | try { a; b; c } catch {} |
❌ | ❌ | 1 层 | 失败即整体失败 |
| 分别 try/catch | 每段一个 try | ✅ | ✅ | N 层 / 变量外提 | 步骤少 |
to() 二元组 |
const [e, d] = await to(p) |
✅ | ✅ | 0 层 | 多步、需降级 ⭐ |
.catch() 兜底值 |
p.catch(() => 默认值) |
❌ | ✅ | 0 层 | 只要默认值,不关心错误 |
五、优点(为什么要用)
- 消灭缩进地狱:
await全部平铺,不用层层try。 - 错误可区分:每个
err对应一个具体步骤,定位快。 - 支持部分降级:某一步失败可以继续往下走(订单失败仍渲染用户)。
- 强制你处理错误:解构出
err就在眼前,不像catch可以被遗忘。 - TS 类型友好:
if (err) return之后data收窄为非空(配合[Error, null] | [null, T]判别联合)。 - 不吞异常:错误以返回值形式存在,可以选择继续抛、上报或忽略。
- 易组合:可以包在重试、loading、埋点里(
await to(retry(fn, 3)))。 - 与 Go / Rust 生态一致:
[err, data]≈ Go 的v, err,Result ≈ Rust 的Result<T, E>,心智负担低。
六、缺点与代价(必须知道)
| 缺点 | 说明 / 规避 |
|---|---|
每个都要 if (err) |
比 try/catch 多几行样板;简单场景反而啰嗦 |
| 可能忘记判断 | 不解构 err 直接就用 data → 拿到 null;建议 ESLint 约束或改成 Result 对象风格 |
| 异常语义被弱化 | 错误变成普通值,容易"忘记向上抛";团队要约定哪些错必须抛 |
| 丢失 throw 的位置语义 | 不像 throw 那样天然有调用栈语义(原始 err.stack 仍在,可用 cause 补充上下文) |
| 需要额外函数/依赖 | 自己实现 10 行,或用 await-to-js |
| 默认值陷阱 | 失败返回 null 时,const [e, data = []] = ... 不会生效(默认值只对 undefined 生效)⚠️ |
// ⚠️ 陷阱演示
const [err, data = []] = await to(getList())
// 失败时 data === null(不是 undefined),默认值不生效 → 后续 data.map 报错
// ✅ 正确
const list = data ?? []
七、什么时候用 / 不用
需要多步 await?
├─ 否(单个请求,失败即中断)
│ └─ 用 try/catch 或 .catch(),最直接
└─ 是
├─ 任一步失败就整体失败?
│ └─ 一个大 try/catch 就够
├─ 需要区分是哪一步失败 / 需要部分降级?
│ └─ ✅ 用 to()
└─ 只要默认值,完全不关心错误?
└─ 用 p.catch(() => 默认值)
八、TypeScript 增强版
1. 标准二元组(推荐)
export type SafeResult<T> = [Error, null] | [null, T]
export function to<T>(promise: Promise<T>): Promise<SafeResult<T>> {
return promise.then(
(data): SafeResult<T> => [null, data],
(err): SafeResult<T> => [err instanceof Error ? err : new Error(String(err)), null],
)
}
2. 带上下文包装(保留 cause)
export async function toWith<T>(promise: Promise<T>, context: string): Promise<SafeResult<T>> {
try {
return [null, await promise]
} catch (e) {
return [new Error(`${context}失败`, { cause: e }), null]
}
}
const [err, user] = await toWith(getUser(id), '加载用户')
// err.message = '加载用户失败',err.cause = 原始错误
3. Result 对象风格(更不容易漏判)
export type Result<T> = { ok: true; data: T } | { ok: false; error: Error }
export async function safe<T>(p: Promise<T>): Promise<Result<T>> {
try {
return { ok: true, data: await p }
} catch (error) {
return { ok: false, error: error as Error }
}
}
const r = await safe(getUser(id))
if (!r.ok) return handle(r.error)
use(r.data) // TS 自动收窄,r.data 一定存在
二元组
[err, data]可能"忘了判断";Result 对象{ok}必须判断才能取 data,更安全。
4. 批量版
export function toAll<T extends readonly unknown[]>(
promises: T,
): Promise<{ [K in keyof T]: SafeResult<Awaited<T[K]>> }> {
return Promise.all(promises.map(p => to(p as Promise<any>))) as any
}
const [u, o] = await toAll([getUser(id), getOrders(id)] as const)
九、与重试 / loading 组合
async function loadWithRetry() {
loading.value = true
const [err, data] = await to(retry(() => findPage(params), 3))
loading.value = false
if (err) return message.error('查询失败,已重试 3 次')
tableData.value = data.data.records
}
十、常见坑清单
- 默认值陷阱:失败返回
null,解构默认值不生效 → 用?? 默认值。 - 忘记
if (err):拿到null继续用 → 建议用 Result 对象风格或加 ESLint 规则。 - 吞掉错误上报:
if (err) return之前记得report(err)。 to()自身也要 await:const r = to(p)得到的是 Promise,必须await to(p)。- 不要混用:同一段逻辑里别一会儿
to()一会儿try/catch,团队统一风格。 Promise.all里用to要逐个包:Promise.all([to(a), to(b)]),而不是to(Promise.all([a, b]))(后者任一失败整体失败)。
十一、自检 / 练习
Q1:为什么 to() 能避免缩进地狱?
因为它把"控制流式的异常"转成"数据流式的返回值",不再需要嵌套块级作用域。
Q2:const [err, data = []] = await to(getList()),请求失败时 data 是什么?
null(默认值只对undefined生效)→ 后续要用data ?? []。
Q3:什么时候不该用 to()?
单请求且失败即中断、或完全不关心错误只要默认值时,用
try/catch/.catch()更简洁。
练习:
- 把下面代码改成
to()写法,要求:用户失败则中止,订单失败降级为[],详情失败只上报不阻断。try { const user = await getUser(id) const orders = await getOrders(user.id) const detail = await getDetail(orders) render({ user, orders, detail }) } catch (e) { message.error('失败') } - 实现
toWith(promise, context),失败时用{ cause }保留原始错误。 - 用 Result 对象风格重写练习 1,体会"不判断就拿不到 data"的类型约束。
参考
await-to-js(npm,7 行实现的[err, data])neverthrow、effect(函数式Result<T, E>)- Go 错误处理
v, err := f();RustResult<T, E>

浙公网安备 33010602011771号