约定与规则:哪些该机械检查、版本与 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,重试该用什么策略。这层不要做成规则。最多做成「信号」:提示一个人去看一眼,但不阻断。

分层之后,每一层的处理方式就清楚了:

  1. 语法层与类型层、依赖层:做成会失败的检查,进 CI,error 就阻断。
  2. 语境层:写进文档与评审清单,必要时用信号提示,但永远不让它阻断提交。

信号与阻断必须分开,这是整套机制能不能长期存活的关键。阻断只给「机械可证伪」的事实;一旦把需要判断的东西也做成阻断,规则很快就会因为误报被整体关掉。

一个小例子:把清单分层

下面是一份常见的团队约定清单,按上面的四层重新整理(示意,不是某个工具的真实配置):

语法层(扫描器,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 本该提供的保证。

延伸阅读