Effect 里底层错误该在哪里翻译成领域错误?

底层是 SQL 驱动的错误,再往上是 HTTP 客户端的错误,全部沿着 E 一路冒到最外层,结果页面层要认识 SqlError 和 ConnectionReset 才能写代码。换一个数据库,整条调用链的类型都得改。

答案是:在模块边界上用 Effect.mapError 把底层错误换成本层的领域错误,并把原始错误作为 cause 保留下来。 错误类的数量,跟「调用方有几种不同的处理方式」对应,而不是跟函数数量或错误来源对应。

这样做有两个目的,隔离和补充语义,下面逐个看。这篇默认你知道 Effect 里用 Data.TaggedError 定义带标签的错误、E 位置记录可能的失败;不熟悉的话先看 Effect 里的错误为什么写在类型里。

mapError 翻译长什么样?

import { Data, Effect } from "effect"

class UserRepoError extends Data.TaggedError("UserRepoError")<{
  readonly op: string
  readonly cause: unknown
}> {}

const findUser = (id: string) =>
  rawSqlQuery(id).pipe(               // E: SqlError | ConnectionError
    Effect.mapError((cause) => new UserRepoError({ op: "findUser", cause }))
  )
// E: UserRepoError,上层不必知道底层是什么数据库

rawSqlQuery 是占位的底层查询函数,它的 E 是 SqlError | ConnectionError。经过 mapError 之后,上层只会看到 UserRepoError。

翻译到底换来了什么?

两样东西。

隔离:底层换了实现,上层的类型不动。

补充语义:op 字段告诉你是哪个操作失败。

为什么翻译时必须保留 cause?

换了一个漂亮的领域错误,却把底层信息抹掉,线上排查时就只剩一句「失败了」。把原始错误放进 cause,翻译就不会丢信息:上层看到的是干净的领域错误,需要排查时,原始错误还在。

错误类该定义几个?

按「调用方会如何不同地处理」来划分,而不是按「错误从哪里来」来划分。

  • 如果上层对「连接失败」和「SQL 语法错误」的处理方式完全相同(都是报告并放弃),不需要两个类,一个带 cause 的 UserRepoError 足够。
  • 如果上层要对「用户不存在」返回 404,那 NotFound 就必须独立成类,因为调用方要据此走不同的分支。

两头都有代价:每个函数一个错误类,通常是过度设计;整个服务只有一个 AppError,又是设计不足。判断标准始终是同一条,类的数量应当与不同的处理方式的数量对应。

翻译之后,谁来捕获、谁来呈现,是另一个问题,见 Effect 里的错误该由哪一层处理?。

文中代码是示意,没有在这里重新运行,没有给出 effect 的具体版本号;rawSqlQuery 是占位名。粒度划分的原则是一套设计判断,不是 Effect 的规定。