catchFilter 配类型守卫,为什么错误类型收窄过头?

下面这段代码能通过编译,类型说残余错误只剩 Timeout;我实际运行,HttpError(404) 仍然以 HttpError 失败。类型说谎了。

原因是 Filter.fromPredicate 配类型守卫时,类型层按守卫类型整体做了 Exclude:守卫只证明「通过的是 HttpError」,却被当成「所有 HttpError 都通过」。规避办法是别让类型守卫承担额外的值条件。

这个坑不是 catchFilter 的 bug,而是类型守卫能表达的信息比你写进谓词里的条件少。catchFilter 与 Filter 的基本用法见 Effect.catchFilter 怎么同时匹配并提取错误?,下面只讲这个坑。

能编译却说谎的代码长什么样?

import { Data, Effect, Filter } from "effect"

class HttpError extends Data.TaggedError("HttpError")<{ readonly status: number }> {}
class Timeout extends Data.TaggedError("Timeout")<{ readonly ms: number }> {}
type Err = HttpError | Timeout

const call: Effect.Effect<string, Err> = Effect.fail(new HttpError({ status: 404 }))

const serverError = Filter.fromPredicate(
  (e: Err): e is HttpError => e._tag === "HttpError" && e.status >= 500
)

export const program = call.pipe(Effect.catchFilter(serverError, () => Effect.succeed("recovered")))

export const lie: Effect.Effect<string, Timeout> = program

谓词里写的是「HttpError 且状态码不小于 500」,但类型守卫的签名只能写成 e is HttpError。lie 这一行能通过编译,意味着类型认为 program 的错误只可能是 Timeout。运行时 HttpError(404) 不满足状态码条件,没有被恢复,以 HttpError 失败。

同样的问题还会出现在哪里?

Effect.catchIf 配带额外条件的类型守卫是同一个问题,我也跑过,结果一致。共同点是:守卫签名只描述类型,守卫函数体里的值条件在类型层面消失了。

怎么规避?

两种办法,都是不让类型守卫去承担「额外的值条件」:

  • 用 Filter.tagged 只按 tag 匹配,值条件放进处理器里,并用 orElse 重抛未处理的情况。Filter.tagged 的残余错误类型是精确的,Filter.tagged<Err>()("Timeout") 之后错误类型是 HttpError | Decode。
  • 像下面这样用 Filter.make,未命中分支返回 Result.fail(e),Fail 类型保持完整输入类型,不会被收窄:
import { Effect, Filter, Result } from "effect"
import { call, type Err } from "./errors.ts"

export const notFound = Filter.make((e: Err) =>
  e._tag === "HttpError" && e.status === 404 ? Result.succeed(e.body) : Result.fail(e)
)

export const program = call.pipe(
  Effect.catchFilter(notFound, (body) => Effect.succeed(`not found: ${body}`))
)

这里 program 的错误类型仍是 Err,不会被收窄成「除 404 以外」。这是诚实的:TypeScript 没法表达「不是 404 的 HttpError」,宁可保守也不要说谎。

那类型守卫式的 fromPredicate 什么时候可以用?

守卫条件和值条件完全等价时:比如只按 _tag 判断,守卫通过就等于整个成员都通过,Exclude 是对的。一旦谓词里还有状态码、字段值之类的额外条件,就别用守卫签名。我的做法是遇到值条件直接用 Filter.make。

以上基于 effect@4.0.5,lie 那段代码我在本地编译并运行过,确认能通过编译且 404 仍以 HttpError 失败;catchIf 配带额外条件的类型守卫也跑过,结果一致。「守卫与值条件等价时可以用」是我由此推出的结论,没有单独验证;规避办法里的 notFound 示例(errors.ts 的定义见主文章)没有在这里再单独运行。