Effect.promise 和 Effect.tryPromise 该用哪个?

网络请求失败,大概是程序里最可预期的一种失败了。可是把 fetch 包进 Effect.promise,这次失败却会被当成不可预期的缺陷。

会失败的外部调用,一律用 Effect.tryPromise,并在 catch 里把原始异常翻译成你自己的带标签错误;Effect.promise 只留给确定不会 reject 的 Promise。

原因在于 Effect 把失败分成两类:放在类型里、可以被处理的失败,和不在类型里的缺陷(defect)。两者的区别见 类型化的错误。选错包装函数,就是把一次本该进类型的失败,送进了缺陷这一边。

Effect.promise 的约定是什么?

// 歪:fetch 失败会被当成缺陷
const getJson = (url: string) =>
  Effect.promise(() => fetch(url).then((r) => r.json()))

Effect.promise 的约定是「这个 Promise 不会 reject」。你对它作出这个承诺,它就不为失败留位置,返回的 Effect 在类型里没有错误通道可言。

如果 Promise 真的 reject 了呢?

它变成缺陷。缺陷不在类型里,也不会被 catchTag 捕获。我在 effect 4.0.5 里跑过:Effect.promise 里 reject 的 Promise 确实变成缺陷,不会进入 Effect.gen 里的 catch。

所以问题不只是「错误丢了类型」,还有调用者根本没有办法精确处理它。网络请求失败是最可预期的失败,却被当成了不可预期的。

tryPromise 应该怎么写?

// 正:把可预期的失败放进 E
const getJson2 = (url: string) =>
  Effect.tryPromise({
    try: () => fetch(url).then((r) => r.json()),
    catch: (cause) => new FetchError({ url, cause })
  })

try 里是原来的调用,catch 里把原始异常翻译成你自己定义的错误,这里是带标签的 FetchError,类型里的错误位置(E)就有了它。这样调用者可以用 catchTag 只处理这一种失败。带标签错误的设计见 错误设计,把外部世界包进 Effect 的整体做法见 包装外部世界。

catch 里直接返回 Error 行不行?

不行,这是同一个问题的另一半。

Effect.fail(new Error("user not found"))   // E 变成 Error
Effect.tryPromise(() => query())           // 不写 catch 时,E 是笼统的 UnknownError

当所有错误都是同一个类型,调用者无法区分「用户不存在」和「数据库挂了」,只能全部当作同一种处理,要么全吞要么全抛。你付出了写 Effect 的成本,却把错误通道退化成了 try/catch。

把 Effect.promise 改成 tryPromise,但 catch 里仍然返回笼统的 Error,等于没改。每种可预期的失败一个带标签的错误,在边界处翻译,catchTag 才能精确地只处理你要处理的那一种,其余的自动保留在类型里。

版本是 effect 4.0.5。Effect.promise 里 reject 的 Promise 会变成缺陷,这一点在本地跑过。其余结论包括 catchTag 不捕获缺陷、不写 catch 时的错误类型,以及上面的代码,都是示意,没有逐条运行验证。