opencode 的 Effect Schema 迁移指南:领域模型、边界规则与迁移顺序
本篇指南基于 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 grepbefore starting a migration.
这意味着两件事:
- Effect Schema 拥有类型定义权:领域模型、数据传输对象、标识符、API 输入输出、以及预期的领域错误,全部由 Effect Schema 声明,其他层从这里派生,而不是各自维护一份。
- 规范是"指导"而非"清单":它刻意不跟踪哪些模块已完成迁移——动手前必须用
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 定义各自领域的 Info、Input 等导出对象。
本地形状与简单嵌套对象: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 状态"的领域,规范给出固定的推进顺序:
- 共享叶子模型与 branded ID——最底层、被引用最多的部分先稳定下来;
- 导出的
Info、Input、Output与事件载荷类型——对外可见的数据面; - 预期的领域错误——让错误通道与数据面同样类型化;
- 服务内部模型——只有前面几层稳定后,内部形状才有可靠的组合素材;
- HTTP / 工具 / AI 边界校验器——最后才动边界,因为边界是"派生消费者",其上游必须先定型。
并附一条硬性约束:
Keep public wire shapes stable unless the PR is explicitly a breaking API change.
换言之,内部类型的迁移不应改变公开 JSON / OpenAPI 输出。从源码结构看,仓库的迁移正是按此推进的:config、account 等模块已大面积采用 Schema.Class,protocol 包 与服务端路由的错误面已普遍是 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对照同类领域的现状,是最稳妥的做法。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00