Effect catchReason:处理「父错误 + 嵌套 reason」的两级错误
Effect 类型化错误那一篇讲过,catchTag 按 _tag 精确恢复。但 v4 里一个越来越常见的错误形状,catchTag 只能做到一半:外层是一个稳定的「父错误」,里面的 reason 字段才是真正的、可枚举的失败原因。
effect/ai 的 AiError 把 RateLimitError、QuotaExhaustedError、ContentPolicyError 等收进 reason;effect/http 的 HttpClientError 把 TransportError、StatusCodeError、DecodeError 等收进 reason。这两个模块在 4.0.5 里都标注为 @stability unstable,细节可能变,但「父错误 + tagged reason」这个模式本身,已经有一组 stable 的 Effect 组合子来处理。
这一篇讲这组组合子:Effect.catchReason、Effect.catchReasons、Effect.unwrapReason,以及它们和手写二级 switch、catchTags 的取舍。下文代码基于 effect@4.0.5 验证。
为什么需要它:catchTag 只下到第一层
先定义一个典型的两级错误。父错误 AiError 带一个 reason,reason 是三个带 _tag 的变体之一:
import { Data, Effect } from "effect"
export class RateLimited extends Data.TaggedError("RateLimited")<{ readonly retryAfter: number }> {}
export class QuotaExceeded extends Data.TaggedError("QuotaExceeded")<{ readonly limit: number }> {}
export class InvalidRequest extends Data.TaggedError("InvalidRequest")<{ readonly detail: string }> {}
export class AiError extends Data.TaggedError("AiError")<{
readonly reason: RateLimited | QuotaExceeded | InvalidRequest
}> {}
export class NetworkError extends Data.TaggedError("NetworkError")<{ readonly cause: unknown }> {}
export declare const callModel: Effect.Effect<string, AiError | NetworkError>只想处理限流时,用 catchTag 只能抓到整个 AiError,然后手写第二层分支:
import { Effect } from "effect"
import { callModel } from "./model.ts"
export const manual = callModel.pipe(
Effect.catchTag("AiError", (e) => {
switch (e.reason._tag) {
case "RateLimited":
return Effect.succeed(`retry after ${e.reason.retryAfter}s`)
case "QuotaExceeded":
return Effect.succeed(`quota ${e.reason.limit}`)
default:
return Effect.fail(e)
}
})
)这段代码能跑,我实际执行过:限流和配额返回字符串,InvalidRequest 原样重新失败。问题不在功能,在结构:
- 每个调用点都重复「抓父错误、switch reason、default 重抛」这套样板。
default: Effect.fail(e)之后,结果类型里还是完整的AiError,编译器不知道你已经处理了哪几个 reason。- 将来
reason联合里加了新变体,这个 switch 不会报错,只会悄悄落进default。
这三点的共同根源是:「第一层靠 _tag 路由,第二层靠手写分支」是两套机制,却描述同一件事。catchReason 的价值就是把它合成一个概念。
catchReason:精确抓一个 reason
import { Effect } from "effect"
import { callModel } from "./model.ts"
export const one = callModel.pipe(
Effect.catchReason("AiError", "RateLimited", (r) =>
Effect.succeed(`retry after ${r.retryAfter}s`)
)
)参数依次是:父错误的 _tag、reason 的 _tag、处理器。处理器拿到的已经是收窄后的 reason(这里是 RateLimited,所以能直接读 retryAfter)。
几条我验证过的行为:
- 命中时返回处理器的结果;
InvalidRequest、QuotaExceeded等未命中的 reason,以及NetworkError这类压根没有 reason 的错误,都原样保持失败。 - 未命中时错误类型保持为
AiError | NetworkError,没有被收窄。父错误仍在 E 里,因为它可能还装着别的 reason。 - 父 tag 写错、或者父错误根本没有 tagged reason(比如把
"NetworkError"当父),是编译错误,不是运行时才发现。reason tag 写错同理。
第四个参数:兜底其余 reason
catchReason 可以带第四个参数,处理「同一个父错误下其余所有 reason」:
import { Effect } from "effect"
import { callModel, type NetworkError, type QuotaExceeded, type InvalidRequest } from "./model.ts"
export const withElse = callModel.pipe(
Effect.catchReason(
"AiError",
"RateLimited",
(r) => Effect.succeed(`retry after ${r.retryAfter}s`),
(rest) => Effect.fail(rest)
)
)
export const typed: Effect.Effect<string, QuotaExceeded | InvalidRequest | NetworkError> = withElse注意类型:带兜底之后,AiError 从 E 里消失了,取而代之的是兜底处理器失败出来的东西。这里我把剩下的 reason 直接 Effect.fail 出去,于是 E 变成 QuotaExceeded | InvalidRequest | NetworkError,父错误被「拆开」了。
官方示例里还有一个很实用的写法:只想把一个 reason 提升成顶层错误,其余都当缺陷。
import { Effect } from "effect"
import { callModel } from "./model.ts"
export const promote = callModel.pipe(
Effect.catchReason("AiError", "InvalidRequest", Effect.fail, Effect.die)
)Effect.fail 把 InvalidRequest 提升为顶层预期错误,Effect.die 把其余 reason 变成 defect。我跑过:限流与配额变成 Die,InvalidRequest 保持 Fail,NetworkError 不受影响。这种「我只关心一种,其余视为 bug」的边界在 HTTP handler 里很常见,ai-docs 的 HTTP server 示例就是这么用的。
catchReasons:一次处理多个 reason
处理两个以上 reason 时,用对象形式:
import { Effect } from "effect"
import { callModel } from "./model.ts"
export const many = callModel.pipe(
Effect.catchReasons("AiError", {
RateLimited: (r) => Effect.succeed(`retry after ${r.retryAfter}s`),
QuotaExceeded: (r) => Effect.succeed(`quota ${r.limit}`)
})
)对象的键是 reason 的 _tag,键可以缺省,没写的 reason 保持失败。和 catchTags 的形状一致,只是多了一个父 tag 参数。
这里有个我在编译器里亲眼确认的坑:不带兜底的 catchReasons,即使你把所有 reason 都写全了,结果类型里仍然保留 AiError。我给三个 reason 全部提供了处理器,把结果赋给 Effect<string, NetworkError> 依然是类型错误,必须写成 Effect<string, AiError | NetworkError>。类型系统不会因为你「覆盖了联合的全部成员」就去掉父错误。
想让父错误真正消失,有两条路:给 catchReasons 加兜底处理器作为第三个参数,或者改用下面的 unwrapReason。
import { Effect } from "effect"
import { callModel, NetworkError } from "./model.ts"
export const closed = callModel.pipe(
Effect.catchReasons(
"AiError",
{ RateLimited: (r) => Effect.succeed(`retry after ${r.retryAfter}s`) },
(rest) => Effect.fail(new NetworkError({ cause: rest }))
)
)
export const closedT: Effect.Effect<string, NetworkError> = closed兜底处理器拿到的是「去掉已处理 reason 之后」的剩余 reason,类型随之收窄。上面例子里我把剩余 reason 折叠成一个 NetworkError,E 就只剩 NetworkError。
unwrapReason:把 reason 提升到错误通道
如果你的恢复逻辑更像 catchTags 那种「按错误种类分派」,可以先把 reason 拆出来:
import { Effect } from "effect"
import { callModel } from "./model.ts"
export const unwrapped = callModel.pipe(
Effect.unwrapReason("AiError"),
Effect.catchTags({
RateLimited: (r) => Effect.succeed(`retry after ${r.retryAfter}s`),
QuotaExceeded: (r) => Effect.succeed(`quota ${r.limit}`)
})
)unwrapReason("AiError") 之后,E 从 AiError | NetworkError 变成 RateLimited | QuotaExceeded | InvalidRequest | NetworkError:父错误被替换成它的 reason,其他错误原样透传。接着 catchTags 处理哪几个,E 里就剩哪几个,这里剩 InvalidRequest | NetworkError,我用类型注解确认过。
代价也要说清:父错误上的上下文,比如 AiError 的 module、method,在 unwrap 之后就拿不到了。恢复逻辑如果需要这些字段,就别 unwrap,用 catchReason,它的处理器第二个参数可以拿到父错误。
用真实的 HttpClientError
HttpClientError 是 effect/http 里的 reason 形状错误,下面用它写一个「404 当作不存在,传输失败当作离线,其余状态码继续失败」的例子。注意它在 4.0.5 里是 @stability unstable:
import { Effect } from "effect"
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"
const request = HttpClientRequest.get("http://localhost/users/1")
const respond = (status: number): Effect.Effect<string, HttpClientError.HttpClientError> =>
status === 200
? Effect.succeed("ok")
: Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response: HttpClientResponse.fromWeb(request, new Response(null, { status }))
})
})
)
export const program = (status: number) =>
respond(status).pipe(
Effect.catchReasons("HttpClientError", {
StatusCodeError: (r) => (r.response.status === 404 ? Effect.succeed("missing") : Effect.fail(r)),
TransportError: () => Effect.succeed("offline")
})
)我实际跑了 200、404、500 和一个 TransportError:分别得到 ok、missing、StatusCodeError 失败、offline。
这里有一个细节值得留意:处理器里 Effect.fail(r) 失败出去的是 reason(StatusCodeError)本身,而不是原来的 HttpClientError。也就是说,处理器的失败类型会直接进入结果的 E,和父错误是否保留互不相干。
AI 侧同理。AiError.AiError 的 reason 是 RateLimitError、QuotaExhaustedError、ContentPolicyError、InvalidRequestError、NetworkError 等十几个变体,我用 AiError.make 构造了一个限流错误,catchReason("AiError", "RateLimitError", ...) 和 unwrapReason("AiError") 都能通过 tsc 并给出正确的类型。读 retryAfter 时要注意它在 reason 上是可选的 Duration。
怎么选
- 只关心一个 reason,其余原样放行:
catchReason。 - 同一个父错误下两三个 reason 要分别处理:
catchReasons。 - 要把父错误彻底消掉:给
catchReason或catchReasons加兜底,类型才会收窄。 - 恢复逻辑本质是按种类分派,且不需要父错误的上下文:
unwrapReason加catchTags。 - reason 不是 tagged(比如只是个字符串字段):这组组合子不适用,退回手写分支。
- 想按「父错误的某个非 tag 字段」或自定义条件匹配:看下一篇的
catchFilter。
我自己的取舍很简单:reason 联合有三个以上成员、且调用点不止一处时,一律不手写二级 switch。手写版的维护成本全部压在「新增 reason 时没人提醒」上,而 catchReasons 的对象键带着类型,写错或者写不存在的 reason 会直接编译失败。
边界与坑
- 这组组合子只下一层:reason 里再嵌 reason,不会递归。
- 它们属于「预期错误」通道的恢复,defect 和 interrupt 不在其中,和
catchTag一致。 - 兜底之前,父错误始终留在 E 里,即使覆盖了全部 reason。想要「穷尽即消失」,就用兜底或 unwrap。
- 处理器的失败类型会进入结果 E,所以在处理器里
Effect.fail(r)会把类型从父错误换成 reason。 AiError与HttpClientError本身是 unstable,字段和 reason 集合在后续版本可能调整;catchReason这一组是 stable。
小结
当错误形状是「稳定的父错误 + 可枚举的 tagged reason」,手写二级 switch 把两种机制混在一起,既重复又不会在 reason 新增时提醒你。catchReason 抓一个、catchReasons 抓多个、unwrapReason 把 reason 提升成顶层错误,让第二层和第一层使用同一套 tag 路由。记住两点:未带兜底时父错误永远留在 E 里;unwrap 会丢掉父错误上的上下文。