约定与规则:哪些该机械检查、版本与 unstable 的管理
前面 21 章讲了许多「应该怎么写」:运行只发生在边缘,错误放进类型,依赖走 Layer,配置走 Config。每一条单独看都有道理。到了团队里,问题换了一个形态:这些道理靠什么保持成立?
这一章不引入新的 Effect API,讲的是治理:一条约定应该由谁来守护,以及你依赖的那个库自己的稳定性怎么管。
要解决的问题
约定有一个共同的命运:写下来的时候人人同意,三个月后没人记得全。新人没读过文档,老人在赶工期时觉得「这次例外」,评审的人只能看到 diff,看不到 diff 之外的约定被违反。文档不会报错,过期也不会报错。
所以我的判断标准很直接:**一条约定如果不能被机器反驳,它就只是愿望。**但反过来也不对,不是所有约定都能交给机器。把「这个服务拆得是否合理」写成 lint 规则,只会得到一堆误报和一群学会绕过规则的人。
关键在于分清哪些该机械化,哪些不该。
朴素做法为什么不够
朴素做法有两种,各有各的失败方式。
第一种是「全靠文档加评审」。它的失败模式是约定的执行成本全部压在人的注意力上,而注意力是最先被项目压力挤掉的东西。
第二种是「什么都上 lint」。它的失败模式是规则数量膨胀,每条规则都有例外,例外多了规则就被整体关掉。一份没人信任的红灯,比没有灯更糟,因为它训练人忽略红色。
两种做法的共同缺陷是没有回答同一个问题:这条约定的违反,能不能被一个确定的程序判定?
心智模型:按「可证伪性」分层
我把约定按判定它需要什么信息来分四层,越往下越需要人。
- 语法层:只看源码的形状就能判定。例如业务代码里出现了
try/catch、async函数、await、new Error、throw、直接读process.env、Date.now()、setTimeout。这类约定对应前面章节的结论:错误进类型、时间与配置走可替换的服务。判定它们不需要理解语义,扫一遍语法树就够了。 - 类型层:需要类型信息才能判定。最典型的是「悬空的 Effect」:构造了一个 Effect,却既没有
yield*也没有赋值,它永远不会执行。tsc对此没有意见,因为它是合法的 TypeScript,所以需要 Effect 的 language service 诊断来补上。还有「错误通道里有没人处理的类型」也属于这一层。 - 依赖与边界层:需要看包清单和文件的角色。例如「
Effect.run*只允许出现在声明过的入口文件」(第 3 章与第 19 章的不变量),「v4 项目不应该依赖 0.x 线的旧@effect/*包,因为它们的 API 已经并进了effect」,「测试文件必须用@effect/vitest」。这些约定要靠一份显式声明(哪些文件是入口、项目是什么形态)来判定。 - 语境层:只有读过上下文的人才能判定。服务该怎么切分,一个错误应该在哪一层翻译(第 6 章),要不要给这段逻辑加 span,重试该用什么策略。这层不要做成规则。最多做成「信号」:提示一个人去看一眼,但不阻断。
分层之后,每一层的处理方式就清楚了:
- 语法层与类型层、依赖层:做成会失败的检查,进 CI,
error就阻断。 - 语境层:写进文档与评审清单,必要时用信号提示,但永远不让它阻断提交。
信号与阻断必须分开,这是整套机制能不能长期存活的关键。阻断只给「机械可证伪」的事实;一旦把需要判断的东西也做成阻断,规则很快就会因为误报被整体关掉。
一个小例子:把清单分层
下面是一份常见的团队约定清单,按上面的四层重新整理(示意,不是某个工具的真实配置):
语法层(扫描器,error 阻断)
- 业务代码禁止 try/catch、async/await、throw、new Error
- 禁止直接读 process.env,改用 Config
- 禁止 Date.now()、setTimeout,改用 Clock 与 Effect.sleep
类型层(language service,error 阻断)
- 悬空的 Effect(floatingEffect)
- 已过时的 API(outdatedApi,迁移期很有用)
依赖与边界层(清单 + 扫描器,error 阻断)
- Effect.run* 只允许出现在声明过的入口
- v4 项目不得依赖 0.x 线的 @effect/* 包
- 禁止 import * as Effect from "effect" 这种命名空间导入
语境层(评审清单,不做成规则)
- 服务边界是否合理
- 错误是否在正确的层翻译
- 是否需要可观测性注意第一层里的每一条,都是前面某一章的「为什么」被压缩成了一个可执行的检查。规则的文案应该只存在于一个地方:如果同一条规则的提示语同时存在于文档、lint 配置和 wiki 里,其中一份迟早会过期。事实只放一处,其余都从它生成,这是让约定不腐烂的唯一办法。
规则本身还要有出口:每条规则要能说清它保护的是哪个不变量,否则没人知道什么时候可以删。已经没有意义的规则应该被明确地淘汰,而不是留着变成噪声。想看一个完整的工具链例子,可以读下面延伸阅读里那篇关于规则合同的文章。
版本与 unstable:你依赖的库自己也有稳定性
治理的另一半,是对依赖本身的管理。Effect v4 在这件事上的设计,有一个容易踩的新陷阱。
在 v4 的 beta 阶段,不稳定的模块放在 effect/unstable/* 路径下,路径本身就是警告。到了 4.0.5,这条路径已经不存在了,稳定性改由每个导出上的 JSDoc 标签表达:@stability stable、@stability unstable、@stability experimental。
这样设计的好处是「晋升为稳定」不再需要所有用户改 import。代价是路径里没有 unstable,不等于它稳定。HTTP、RPC、SQL、AI 这些模块现在直接在 effect/http、effect/rpc、effect/sql、effect/ai 下,但其中大部分仍然带着 unstable 标签。如果你只看 import 路径,完全看不出来。
所以需要一条治理规则:让 unstable 的使用变成可见的、有名单的。
- 默认让工具对 unstable 与 experimental 的 API 报警告,团队里每个用法都会被看见。据
@effect/tsgo的 README,language service 里相应的诊断是unstableApiUsage与experimentalApiUsage,并支持放行名单。这些诊断名取自该项目 README,本文没有逐一运行验证,使用前请以你安装的版本为准。 - 对决定接受风险的模块,在放行名单里显式写下来。这份名单就是「我们依赖的 unstable 清单」,升级前逐项对照变更记录。
- 放行的粒度越小越好:放行单个导出,而不是整个模块。
- 如果你发布的是库,要特别留意公开类型里是否暴露了稳定性更低的类型,否则你的使用者间接依赖了它,你的版本承诺就悬空了。
再说版本固定。一个 unstable 模块在小版本里就可能有破坏性变更,所以依赖 unstable 模块的项目,应该固定到精确版本(不用 ^),升级当成一次有计划的动作:读变更记录,对照放行名单,跑完类型检查与测试再合入。相反,如果项目只用 stable 的 API,常规的语义化版本范围就足够了。
还有一条属于「依赖卫生」:一个 v4 项目里出现了 0.x 线的 @effect/* 包,几乎总是迁移没做干净的信号,因为那些包提供的 API 现在已经并进了 effect 本体。这个判断同样是机械可证的,看 package.json 就够了。
常见误区
- **把所有约定都做成阻断。**语境层的东西做成阻断,误报会让规则被整体关闭。判断需要上下文的,用信号或评审。
- **规则写成一次性的。**规则文案、文档、示例代码各存一份,改一处漏两处。让规则只有一个来源,其余内容由它生成。
- **以为没有 unstable 路径就等于稳定。**4.0.5 里稳定性在标签里,不在路径里,需要工具或名单去看。
- **用宽松范围依赖 unstable 模块。**一次小版本升级可能带来破坏性变更,而你并不知道。
- **规则只管「不许」,不告诉人「改成什么」。**一条只会报红不给出路的规则,只会被人想办法绕开。每条规则的提示里,都应该指向替代写法对应的那一章。
小结
约定的价值在于被持续遵守,而持续遵守靠的是机器能反驳。按判定所需的信息把约定分成语法、类型、依赖与语境四层:前三层做成会失败的检查,语境层留给人。版本方面,4.0.5 用标签而不是路径标注稳定性,所以 unstable 的使用要靠名单和工具让它显形,依赖 unstable 模块的项目要固定精确版本。
下一章是最后一章:把前面各章里出现过的错误用法集中起来,看看 Effect 用歪的十种典型方式,以及每一种为什么会让你失去 Effect 本该提供的保证。
延伸阅读
- 把 Effect 约定变成机器可检查的规则:语法层、类型层与信号的完整工具链例子。
- Effect v4 的稳定性标注:@stability 标签的机制与 4.0.5 里 unstable 模块的清单。
- Effect v3 到 v4 迁移:迁移期的版本与旧 API 治理。