别用断言把类型错误压过去:用 anti-slop 为 TypeScript 建立证据优先的 Oxlint 规则
TypeScript 项目里最难清理的往往不是明显的 any,而是看起来“已经有类型”的补丁式代码:把值连续 as 成目标类型;先标注成 unknown 或宽泛的 Record,过几行再断言回来;对来自 HTTP、环境变量或 JSON 的值到处写 typeof。这些写法能让当前文件通过检查,却把真正的契约推迟到调用链深处。代码失效时,读者无法判断一个值是经过解析得到的,还是仅被开发者声明为可信。
anti-slop 是 Dillon Mulroy 发布的 MIT 许可证 TypeScript 项目。它把一组偏严格的 Oxlint 规则做成源码分发的插件,目标并不是替代类型系统或运行时校验,而是拒绝一些“丢弃已有证据、再用断言伪造回来”的低信号模式。项目目前尚未发布 npm 包;官方建议使用随仓库提供的 agent skill,或将 src/ 复制到自己的仓库后注册为本地插件。
这个定位需要先说清边界:规则报错并不等于业务逻辑一定错误;反过来,lint 通过也不能证明外部数据有效。它的价值是把团队讨论从“这里再加一个断言能不能过”转为“证据在哪个边界建立、能否一路保留”。对 API 客户端、配置加载器、表单提交和消息消费者这类输入边界尤其有帮助。
为什么“能收窄”不等于“已经验证”
例如某段代码收到 unknown 后,常见做法是通过 typeof 和断言临时让编辑器满意:
function readPort(input: unknown): number {
if (typeof input === "number") {
return input as number;
}
return 3000;
}
这里的 typeof 对一个简单数字的确有意义,但如果同样的模式被复制到对象、数组和嵌套字段,应用会形成大量分散且不完整的校验点。anti-slop 的 no-runtime-typeof 明确要求:把外部值在 I/O 边界解码为有意义的领域类型,而不是在使用点临时缩窄未解析的表示。
实际替换方式不必神秘:为一个边界设置专门的解码步骤,让它返回确定类型或明确失败。以端口为例,配置加载器应先确认值是整数且处于允许范围,再将它交给应用的 Port 类型;对 JSON 或复杂请求体,团队可用现有 schema/decoder 库实现同一思路。anti-slop 本身不提供解析器,也不替你选择验证库。
这里还有一个必须提前验证的取舍:当前 no-runtime-typeof 的源码会报告所有 typeof 一元表达式,并不是只识别“未解析外部值”的复杂场景。因此,手写的 typeof 解析器也会触发它。采用这条规则前,应确认团队已有的 decoder 或 schema 方案是否符合预期;不能把 README 中“在边界解析”的设计意图误读为插件会自动识别所有正确的手写解析代码。
先以本地插件方式接入
在项目尚未发布 npm 包的阶段,最可控的接入方式是把其 src/ 目录纳入仓库,例如放在 tools/oxlint/anti-slop/。随后安装与当前项目兼容的 oxlint 和 @oxlint/plugins,再在 oxlint.config.ts 注册入口。以下配置来自项目 README 所示的规则名;可先只启用两三条,确认影响面后再提高严格度:
import { defineConfig } from "oxlint";
export default defineConfig({
jsPlugins: [
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
],
rules: {
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
},
});
仓库入口文件注册了十条规则。除了上面的四条,还包括禁止条件空对象展开、宽泛 object 形参、符号名中的 shape、除特定 cause 约定外的 unknown 参数、仅用于隐藏 unknown 的别名,以及运行时 typeof 缩窄。它也说明同样的 jsPlugins 条目与规则可以放入 Vite+ 配置中的 lint 字段;不要据此推断所有 Vite 配置格式都完全相同,应以所用框架版本的配置文档为准。
如果团队使用支持 skills 的编码 Agent,官方提供的安装入口是:
npx skills add dmmulroy/anti-slop --skill install-anti-slop
该 skill 的声明工作是复制插件、安装当前 Oxlint 依赖、合并既有 lint 配置、启用规则并验证结果。它会改动仓库,因此更适合在分支上运行并检查 diff,而不是直接对主干执行。想只查看可用 skill,可将最后一个参数换为 --list。
三类最值得先处理的违规
第一类是连续断言。value as unknown as User 常被当作绕过不兼容类型的快捷方式,但插件的 no-chained-type-assertions 会拒绝嵌套的 as 与尖括号断言链;源码对纯 const 断言链保留例外。修复时不要简单换一个更长的断言,而要回到数据来源:若 value 来自外部,解析;若来自内部函数,修正该函数的返回类型。
第二类是先宽化、后重建。下面的写法把对象已知的结构扔进宽泛容器,又在使用处要求编译器相信它仍是精确类型:
type User = { id: string; email: string };
const raw: Record = { id: "u_1", email: "a@example.test" };
const user = raw as User;
no-widen-then-assert 针对的是这种同一局部范围内、不可变绑定的证据丢失再重建。若对象本来由可信代码构造,让 TypeScript 保留推导结果即可;若它确实来自网络或文件,应该解析到 User,而不是把 Record 当作中转站。
第三类是条件空对象展开。{ ...(enabled ? { timeout } : {}) } 会让对象字段是否存在隐藏在展开表达式中,阅读者和后续类型处理都要额外推理。插件的 no-conditional-empty-object-spread 会报告该模式;在“检查某变量是否为 undefined,且两侧值相同”的特定形态下,源码还提供直接属性替换的自动修复。对更复杂的条件,不要期待自动修复,改为直接字段、分支构造或清晰的 builder 逻辑更安全。
不要在存量项目里一次性全开
这套规则具有明显的设计偏好。例如许多成熟服务会把 unknown 放在通用中间件入口,把类型缩窄分散到各适配器;一些框架也依赖宽字典类型描述扩展点。直接把十条规则全部设为 error,可能制造大量与当前交付无关的迁移工作。
较稳妥的步骤是:先复制并锁定审查过的 src/ 版本;在一个新模块或 CI 非阻断模式启用;按规则类别统计命中位置;优先处理外部输入、权限和持久化数据边界;最后才决定哪些规则应扩展到遗留代码。因为插件为源码分发,升级也应像升级内部工具一样审查 diff,而不是假设未来 npm 版本会自动保持相同行为。
项目自身的开发命令是 pnpm check,它会串联 lint、测试、类型检查和 skill 资产一致性检查;生产源码以 src/ 为准,修改后需要执行 pnpm sync:skill-assets 更新随 skill 打包的副本。即使不参与插件开发,这也提示了一个实践原则:团队复制到自己仓库的规则应有明确来源与更新责任人,否则“本地 lint 插件”很快会变成无人维护的隐式依赖。
anti-slop 适合想把 TypeScript 的类型边界写得更可追溯的团队:它不解决 schema 设计、测试覆盖或运行时安全,但能把最常见的证据逃逸路径暴露在代码审查前。先从少量规则和真正的输入边界开始,通常比在全仓库发起一次“消灭断言”运动更有效。
把 lint 命中变成可执行的审查问题
启用后,最差的处理方式是让开发者为了消除红线而再包一层别名,或把规则整体关闭。每一次命中都可以先问三个具体问题:这个值来自哪里?它第一次被证明满足契约是在何处?如果解析失败,调用方能否看到明确错误?这三个问题会自然区分两种修复。对于内部构造的数据,通常应删掉宽泛注解,让推导类型或函数签名继续传递信息;对于外部数据,则把校验集中到适配器、配置加载或请求解析层,并使其返回领域类型。
CI 也应保留分层。lint 很适合阻止明显的证据倒流,但单元测试仍应覆盖解析器接受与拒绝的输入,集成测试仍要验证真实接口的版本变化。尤其不要把 no-runtime-typeof 理解成“运行时检查一律错误”:规则表达的是插件的严格偏好,复杂边界需要怎样解析、是否使用 schema 库,仍由项目的错误模型、性能约束和依赖策略决定。把这一点写进团队约定,才能避免一条代码质量规则被误用为绝对的安全结论。
anti-slop 适合想把 TypeScript 的类型边界写得更可追溯的团队:它不解决 schema 设计、测试覆盖或运行时安全,但能把最常见的证据逃逸路径暴露在代码审查前。先从少量规则和真正的输入边界开始,通常比在全仓库发起一次“消灭断言”运动更有效。