catchReasons 全部写完,为什么类型里还有父错误?

给 AiError 的三个 reason 全部写了 catchReasons 处理器,把结果赋给 Effect<string, NetworkError>,编译器依然报类型错误,必须写成 Effect<string, AiError | NetworkError>。

不带兜底的 catchReasons,父错误永远留在 E 里;想让它消失,给 catchReasons 加兜底处理器作为第三个参数,或者改用 Effect.unwrapReason。

原因是类型系统不会因为你「覆盖了联合的全部成员」就去掉父错误。catchReasons 的对象形式只管把你写出的 reason 恢复掉,父错误作为容器仍然是 E 的一部分。catchReason 和 catchReasons 的基本用法见Effect 怎么处理「父错误 + 嵌套 reason」的两级错误,下面只讲怎么把父错误真正去掉。

先看一遍这个错误的形状

下面的例子里,AiError 是父错误,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>

加兜底处理器,父错误怎么消失?

给 catchReasons 加第三个参数作为兜底:

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。catchReason 也一样,第四个参数是兜底,加上之后 AiError 同样从 E 里消失,取而代之的是兜底处理器失败出来的类型。

unwrapReason 怎么让父错误消失?

另一条路是先把 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,我用类型注解确认过。

unwrapReason 要付出什么代价?

父错误上的上下文,比如 AiError 的 module、method,在 unwrap 之后就拿不到了。恢复逻辑如果需要这些字段,就别 unwrap,改用 catchReason,它的处理器第二个参数可以拿到父错误。

兜底和 unwrap 怎么选?

  • 恢复逻辑需要父错误的上下文:用 catchReason 或 catchReasons 加兜底。
  • 恢复逻辑本质是按种类分派,且不需要父错误的上下文:unwrapReason 加 catchTags。
  • 只想让某一个 reason 成为顶层错误、其余当缺陷:catchReason 的兜底写成 Effect.die。

还有一个相关的细节:处理器的失败类型会直接进入结果 E,所以在处理器里 Effect.fail(r) 会把类型从父错误换成 reason,和父错误是否保留互不相干。

版本是 effect 4.0.5。本文的类型结论在这个版本上确认过:不带兜底的 catchReasons 即使写全三个 reason,也必须保留 AiError;带兜底后 E 收窄;unwrapReason 加 catchTags 后 E 只剩未处理的部分。AiError 在 4.0.5 里是 unstable,字段和 reason 集合后续可能变化。