Effect Schema 解码失败时,怎么一次返回所有字段的错误?

用户提交了一个注册表单,四个字段都填错了,接口却只告诉他第一个字段有问题。他改完再提交,又只看到第二个。这种来回四次的体验,通常不是校验写得不对,而是解码选项没开。

Schema 解码默认遇到第一个错误就停;传入 { errors: "all" } 后,所有字段的错误会一起累积到同一个 SchemaError 里,再用 SchemaIssue 的 formatter 压平成 { path, message } 列表,就能在边界转成对调用方友好的领域错误。

下面按顺序讲:先看默认行为和打开选项后的区别,再看怎么把错误结构化,最后说这一步为什么该放在边界的最外层。

默认解码为什么只报第一个错误?

默认情况下,解码遇到第一个错误就停止。对内部调用来说这是合理的,失败了就是失败了;但对表单类场景,调用方想一次看到全部问题。

下面是一个创建用户的 Schema,带非空、邮箱格式、整数范围和枚举四类约束:

import { Data, Effect, Schema, SchemaIssue } from "effect"

const Email = Schema.String.check(
  Schema.isPattern(/^[^@\s]+@[^@\s]+\.[^@\s]+$/)
).pipe(Schema.brand("Email"))

const CreateUser = Schema.Struct({
  name: Schema.NonEmptyString,
  email: Email,
  age: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 150 })),
  role: Schema.Literals(["admin", "member"]),
  nickname: Schema.optionalKey(Schema.String),
})

const decodeCreateUser = Schema.decodeUnknownEffect(CreateUser)

Schema.decodeUnknownEffect(CreateUser) 返回的是函数,在模块顶层构造一次,之后到处复用。输入是 unknown,失败时进入 Effect 的错误通道,类型是 SchemaError。

怎么让它一次报出所有错误?

给解码函数传第二个参数 { errors: "all" }:

const decodeBad = decodeCreateUser(
  { name: "", email: "x", age: 1.5, role: "root" },
  { errors: "all" }
)

const asMessage = decodeBad.pipe(
  Effect.catchTag("SchemaError", (e) => Effect.succeed(e.message))
)

输入故意四个字段都不合法。SchemaError 的 _tag 是 "SchemaError",所以可以像别的领域错误一样 catchTag。这段代码的输出是:

Expected a value with a length of at least 1
  at ["name"]
Expected a string matching the RegExp ^[^@\s]+@[^@\s]+\.[^@\s]+$
  at ["email"]
Expected an integer
  at ["age"]
Expected "admin" | "member"
  at ["role"]

四个字段的错误都在,每条带路径。不传这个选项,就只会看到第一条。

为什么不能直接把 e.message 返回给调用方?

e.message 是拼好的文本,适合写日志。给 API 调用方的响应则应该是结构化的:前端要知道哪个字段错了,才能把提示挂到对应的输入框上,而不是去解析一段文本。同时,也不该把内部的错误格式直接吐出去。

怎么把 SchemaError 转成带路径的领域错误?

SchemaError 上有一个 issue 字段,是结构化的错误树。用 SchemaIssue.makeFormatterStandardSchemaV1() 创建的 formatter 可以把它平铺成 issues 列表,每项有 path 和 message。再用 mapError 转成面向 HTTP 的 BadRequest:

class BadRequest extends Data.TaggedError("BadRequest")<{
  readonly issues: ReadonlyArray<{
    readonly path: ReadonlyArray<PropertyKey>
    readonly message: string
  }>
}> {}

const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1()

const parseBody = (body: unknown) =>
  decodeCreateUser(body, { errors: "all" }).pipe(
    Effect.mapError((e) => {
      const result = formatIssues(e.issue)
      return new BadRequest({
        issues: result.issues.map((i) => ({
          path: (i.path ?? []).map((p) => (typeof p === "object" ? p.key : p)),
          message: i.message,
        })),
      })
    })
  )

formatter 的路径元素可能是对象,所以用 typeof p === "object" ? p.key : p 统一成 PropertyKey。对上面那个四字段全错的输入,parseBody 失败时 issues 里有四项,path 分别是 ["name"]、["email"]、["age"]、["role"]。

这一步该放在哪一层?

放在边界的最外层。Schema 的细节,也就是 issue 树的形状,到这里为止;往里走的代码只认识 BadRequest,不需要知道 Schema 的内部格式。这和「底层错误在边界翻译成领域错误」是同一个思路,只是这次被隔离的是解码失败。

parseBody 的成功值是已通过校验的 CreateUser,之后的存储层、业务层可以直接信任它,不需要再校验一遍。

版本是 effect 4.0.5。示例代码与输出取自原指南中在 strict 模式下编译并运行过的版本;这次整理没有重新运行,写进项目前建议自己用编译器和运行结果确认,尤其是 formatter 返回的路径元素形状。