首页
/ opencode 的 Effect Schema 迁移指南:领域模型、边界规则与迁移顺序

opencode 的 Effect Schema 迁移指南:领域模型、边界规则与迁移顺序

2026-09-06 15:12:46作者:戚魁泉Nursing

本篇指南基于 opencode 仓库中的 Effect Schema 迁移规范,系统讲解如何用 Effect Schema 统一承载领域模型、DTO、ID、输入输出与类型化错误。读完之后,你将掌握仓库推荐的三种"形状"(Schema.Class / Schema.Struct / Schema.TaggedErrorClass)及其适用场景、Schema 与 Zod 的边界规则、命名精化(refinement)的写法,以及一个领域从混合 Schema 状态迁移到 Effect Schema 的五步顺序与 PR 检查清单。

核心思想:Effect Schema 是唯一的类型事实来源

规范的开篇即给出定位:

Use Effect Schema as the source of truth for domain models, DTOs, IDs, inputs, outputs, and typed errors.

This is guidance, not an inventory. Do not use this file to track which schema modules are complete; verify current state with git grep before starting a migration.

这意味着两件事:

  1. Effect Schema 拥有类型定义权:领域模型、数据传输对象、标识符、API 输入输出、以及预期的领域错误,全部由 Effect Schema 声明,其他层从这里派生,而不是各自维护一份。
  2. 规范是"指导"而非"清单":它刻意不跟踪哪些模块已完成迁移——动手前必须用 git grep 等工具自行核实现状。这一点很重要:规范只约定"终态长什么样、按什么顺序走",不承诺任何时点的完成度快照。

推荐的形状(Preferred Shapes)

规范为四类对象分别给出了标准写法。以下示例完整继承自规范原文。

导出且有领域身份的数据对象:Schema.Class

export class Info extends Schema.Class<Info>("Foo.Info")({
  id: FooID,
  name: Schema.String,
  enabled: Schema.Boolean,
}) {}

要点在于:Schema.Class 携带一个全局唯一的名字(如 "Foo.Info"),这使得该 Schema 可以被序列化到 JSON Schema / OpenAPI 等下游产物中并被稳定识别。仓库中大量领域模型正是这种形态,例如 account 领域的 schema 模块provider 认证模块config 包 等——它们都以 Schema.Class 定义各自领域的 InfoInput 等导出对象。

本地形状与简单嵌套对象:Schema.Struct

const Payload = Schema.Struct({
  id: FooID,
  value: Schema.String,
})

Schema.Struct 适用于不需要全局身份的内部载荷:不导出、不出现在 wire 契约上、只服务于某个函数或服务的中间形状。判断标准是"这个对象是否需要跨模块被引用"——需要则 Schema.Class,仅本地则 Schema.Struct

预期的领域错误:Schema.TaggedErrorClass

export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
  id: FooID,
}) {}

Tagged 错误把"预期失败"(找不到、冲突、未授权等)变成一等类型:调用方可以按 tag 精确收窄,Effect 的错误通道也能携带结构化载荷。仓库中这一模式已广泛落地,例如 protocol 包的错误定义HTTP 路由的错误处理LLM 层的错误 Schema,均为 Schema.TaggedErrorClass 形态。

单值领域标识符:带品牌(branded)的 Schema 型 ID

规范要求"Use branded schema-backed IDs for single-value domain identifiers"——即 SessionID 不应只是 string,而是有品牌的独立类型,让编译器阻止把会话 ID 误传给工具 ID。仓库的 ID 生成器 印证了这套体系:它为 session/message/permission 等十余种实体定义了独立前缀(ses_msg_per_……),并带前缀校验(传入的 ID 前缀不匹配直接抛错)。品牌类型正是与这种"带前缀的标识符"配合的类型层保护。

边界规则:谁拥有类型,哪里允许例外

规范中最有分量的约束是"边界规则":

Effect Schema should own the type. Boundaries should consume Effect Schema directly or use narrow boundary-specific helpers. Avoid reintroducing a generic Effect Schema → Zod bridge.

即:Effect Schema 拥有类型;各边界(HTTP、TUI、工具、AI SDK)直接消费 Effect Schema,或只使用窄的、边界专用的辅助函数;禁止重新引入一个通用的 Effect Schema → Zod 桥接层。桥接层的危害是众所周知的:它会复制字段定义、掩盖两边的漂移,并让 Zod 变成事实上的第二来源。

规范同时承认了四个当前的有意边界(current intentional boundaries)

边界 现状
公开插件工具 仍通过 tool.schema = z 对外暴露 Zod——这是插件生态的公开契约
工具参数 使用工具专用的 JSON Schema 辅助函数,而非 Zod
公开配置与 TUI schema 生成 经过 schema 生成脚本 统一产出
AI SDK 对象生成 走 Standard Schema / JSON Schema 辅助函数

并附上一条执行纪律:当某处 Zod 必须暂时保留时,要在代码里留下一条简短注释,说明边界原因或兼容性原因。这样"残留的 Zod"要么被 git grep 一查一个准、且都有据可查,不会变成无人敢动的暗角。

精化(Refinements):命名复用,禁止随手 brand

规范建议把常用约束提炼成命名的精化,而不是在每个使用点重复拼写:

const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))

这种写法的好处是:校验逻辑只有一处,错误消息可以携带领域语义,调用点表达的是意图而非实现。规范紧接着给出两条命名原则:

  • 当名字能改善调用点或错误消息时,优先使用领域命名的叶子 Schema(比如 PositiveInt 之于裸的 Number.check(...));
  • 不要"为了新而异"地添加 brand——品牌必须服务于类型安全(防止混用),纯粹的装饰性品牌只会增加噪音。

这条对 config 包 这类以数值型参数(超时、行数上限、并发数)为主的模块尤其关键:PositiveInt 这类叶子一旦命名到位,所有配置解析的错误消息都会从"invalid type: number"变成可理解的领域级提示。

迁移顺序:五步走,公共 wire 形状保持稳定

针对仍处在"混合 Schema 状态"的领域,规范给出固定的推进顺序:

  1. 共享叶子模型与 branded ID——最底层、被引用最多的部分先稳定下来;
  2. 导出的 InfoInputOutput 与事件载荷类型——对外可见的数据面;
  3. 预期的领域错误——让错误通道与数据面同样类型化;
  4. 服务内部模型——只有前面几层稳定后,内部形状才有可靠的组合素材;
  5. HTTP / 工具 / AI 边界校验器——最后才动边界,因为边界是"派生消费者",其上游必须先定型。

并附一条硬性约束:

Keep public wire shapes stable unless the PR is explicitly a breaking API change.

换言之,内部类型的迁移不应改变公开 JSON / OpenAPI 输出。从源码结构看,仓库的迁移正是按此推进的:configaccount 等模块已大面积采用 Schema.Classprotocol 包 与服务端路由的错误面已普遍是 TaggedErrorClass,而 server 路由层 这类"第五层"的边界校验器则是最后收口的部分。

PR 检查清单:五个必须回答的问题

规范的收尾是一份随 PR 提交的自检清单(原文以 checkbox 形式给出,此处完整保留):

  • [ ] 每个被迁移的类型,有且仅有一个 Schema 事实来源(one schema source of truth);
  • [ ] 仍然存在的 Zod,每一处都是有意的边界选择(且按前述纪律留有注释);
  • [ ] 公开的 JSON / OpenAPI 输出未变化,或本 PR 就是有意变更
  • [ ] 派生辅助函数是窄的、边界专用的(narrow and boundary-specific),不是新的通用桥接;
  • [ ] 测试断言的是行为,而不是重复断言 Schema 的实现细节(字段顺序、内部结构等)。

最后一条值得特别强调:测试不应成为 Schema 的第二副本。expect(pick).toEqual({ name: "a" }) 这类行为断言可以,但"逐字段比对解码器内部结构"的断言会在重构时制造连锁破坏,与"唯一事实来源"的精神相悖。

适用前提与限制

  • 本指南描述的是一套进行中的渐进式迁移约定,仓库当前处于"大量领域已迁移、边界层仍在收口"的中间态。文中引用的文件路径只用于佐证形态,不能用来推断哪些模块已经完成迁移——规范要求动手前先用 git grep 核实。
  • 文中的 Foo.Info / FooID 均为规范中的示意代码,用于演示形状约定,不是仓库中的真实导出名。
  • 边界例外(插件 Zod、工具 JSON Schema、schema 脚本、AI SDK 辅助函数)是当前时点的有意决策,未来可能随迁移深入而变化;如果你在为某个新模块做技术选型,先读 Effect 迁移总指南 与本规范,再用 git grep 对照同类领域的现状,是最稳妥的做法。
登录后查看全文
热门项目推荐
相关项目推荐