覆盖率 99% 的代码库,线上照样 undefined 崩了
一个真实感很强的剧本。支付回调接口跑了半年没出事,某天第三方把回调体里的 settlement 字段从"必有"改成"大额分期时才有"。代码里这行安然无恙:
// 一个 as,把类型系统在此之后的全部检查都买断了
const callback = (await resp.json()) as PaymentCallback;
// 半年后在这里炸:Cannot read properties of undefined
const fee = callback.settlement.fee.amount;
编译全绿,测试全绿——fixture 里 settlement 当然是全的。同一周的 code review 还发现另一件事:feature-b 深层 import 了 feature-a 的 internal/queryBuilder,feature-a 重构内部实现,三个业务一起挂。
这两个事故的共同点:问题都出在边界上——运行时与编译时的边界、模块之间的边界。类型系统管不到的地方,需要 lint 规则补位,把"评审时要记住的事"变成机器强制。
lint 只管三类边界,风格全部交给 prettier
先立一个取舍:这份契约只覆盖异步边界、类型边界、模块边界。引号、分号、换行全部是 prettier 的地盘——ESLint 官方也已把格式化类规则标记废弃。判断一条规则该不该开的标准只有一个:它能不能对应一类真实事故,或一段反复出现的评审争论。对应不上的规则,噪音大于保护。
异步边界:floating promise 是第一事故源
漏掉一个 await,rejection 就变成无人处理的 Promise。Node 15 起的默认行为是进程直接退出,不是打条日志继续跑。
// 反例一:忘了 await,失败被吞,上层以为成功了
saveAuditLog(orderId);
// 反例二:void 只声明"我故意不等",rejection 依然没人接
void saveAuditLog(orderId);
// 正解:明确等待,或明确把失败纳入处理路径
await saveAuditLog(orderId).catch(err => reportDegraded(orderId, err));
// eslint.config.mjs(flat config,typescript-eslint v8)
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['src/**/*.{ts,tsx}'],
extends: [tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: {
// true = 用离目标文件最近的 tsconfig;下面两条规则离开类型信息不会生效
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
// await 非异步值多半是把同步逻辑伪装成异步,掩盖真实的控制流错误
'@typescript-eslint/await-thenable': 'error',
},
});
project: true 是 typescript-eslint v6 之后推荐的写法,省掉手工维护 tsconfig 路径清单的成本。
类型边界:any 只许活在解析层
策略是分区而不是全面禁止:外部数据进入的解析层(API client、webhook handler、消息消费者)允许 any 和 unknown 存在,但被 no-unsafe-* 系列逼着在出口前收窄;业务层全面禁 any。
// 解析层出口:守卫函数,过了这道门类型就是可信的
function isPaymentCallback(v: unknown): v is PaymentCallback {
if (typeof v !== 'object' || v === null || !('settlement' in v)) return false;
return typeof (v as { settlement: unknown }).settlement === 'object';
}
const raw: unknown = await resp.json();
if (!isPaymentCallback(raw)) throw new InvalidWebhookError('bad shape');
// settlement 的存在性从此由运行时保证,不再靠信仰
// 解析层收 any 但不许扩散;业务层连 any 都不许有
{
files: ['src/api/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
// 从 any 出发的取值/调用/传参/返回全部报错,逼你在出口处收窄
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
},
},
{
files: ['src/features/**/*.{ts,tsx}'],
rules: { '@typescript-eslint/no-explicit-any': 'error' },
},
@ts-ignore 封禁,@ts-expect-error 必须带原因
@ts-ignore 会永久压制错误,哪怕被压制的问题早已消失或变形;@ts-expect-error 至少有个优点——当那行代码不再报错时,注释本身会变成新的错误,过期压制自动暴露。
'@typescript-eslint/ban-ts-comment': ['error', {
'ts-expect-error': 'allow-with-description', // 允许,但必须写原因
'ts-ignore': true, // 无条件禁止
'ts-nocheck': true,
minimumDescriptionLength: 10, // 逼原因写得像句话
}],
模块边界:feature 之间只认 index
目录约定先行:src/features/<name>/index.ts 是唯一对外出口,旁边的 internal/ 放实现细节,src/shared 放跨 feature 的公共件。入门用核心规则 no-restricted-imports 就能拦住深层 import:
{
files: ['src/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{ group: ['**/features/*/internal/**'],
message: 'feature 内部不对外:请从该 feature 的 index.ts 导入' }],
}],
},
},
团队更大、层级更多时换 eslint-plugin-boundaries,能按元素类型声明"谁能 import 谁":
import boundaries from 'eslint-plugin-boundaries';
{
files: ['src/**/*.{ts,tsx}'],
plugins: { boundaries },
settings: {
'boundaries/include': ['src/features/**', 'src/shared/**'],
'boundaries/elements': [
{ type: 'feature', pattern: 'src/features/*', capture: ['feature'] },
{ type: 'feature-internal', pattern: 'src/features/*/internal/**', capture: ['feature'] },
{ type: 'shared', pattern: 'src/shared/*' },
],
},
rules: {
// 谁能引用谁:feature 只能碰 shared 和自己;shared 只能碰 shared
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
{ from: ['feature'], allow: [['feature-internal', { feature: '${from.feature}' }], 'shared'] },
{ from: ['feature-internal'], allow: [['feature-internal', { feature: '${from.feature}' }], 'shared'] },
{ from: ['shared'], allow: ['shared'] },
],
}],
// 内部文件禁止被元素外部的文件引用:"只认 index"靠它落地
'boundaries/external': ['error', {
default: 'allow',
rules: [{ from: ['feature'], disallow: ['feature-internal'] }],
}],
},
},
插件各版本的选项细节有差异,以所用版本文档为准;结构不变——先声明元素,再声明引用规则。
test.only:让 CI 替你把关
本地调试加了 test.only,提交时忘了删,CI 绿得理直气壮——因为它只跑了一个用例。这不是段子,是每个规模够大的团队都会踩一次的坑。
import noOnlyTests from 'eslint-plugin-no-only-tests';
{
files: ['tests/**/*.ts'],
plugins: { 'no-only-tests': noOnlyTests },
rules: { 'no-only-tests/no-only-tests': 'error' },
},
例外的治理:disable 要有原因、有账本
契约必须有例外通道,否则会被绕过——换成 @ts-expect-error,或干脆把规则挤出配置。治理就三条:
- 格式约定:规则名 +
--+ 原因,原因里带 issue 链接或工单号。 - 失效自清理:被压制的错误一旦消失,disable 本身报错。
- 数量只减不增:CI 里对着基线文件计数。
// 允许的样子
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- 审计日志失败可降级,处理路径见 OPS-1042
notify(orderId);
// 失效的 disable 直接报错(flat config)
linterOptions: { reportUnusedDisableDirectives: 'error' },
#!/usr/bin/env bash
# CI:disable 数量超过基线即失败,基线只能随清理 MR 下降
set -euo pipefail
CURRENT=$(grep -rE 'eslint-disable(-next-line|-line)? ' src tests \
--include='*.ts' --include='*.tsx' | wc -l | tr -d ' ')
BASELINE=$(cat scripts/lint-disable-baseline.txt)
if [ "$CURRENT" -gt "$BASELINE" ]; then
echo "eslint-disable: $BASELINE -> $CURRENT,禁止新增;请清理存量后更新基线" >&2
exit 1
fi
type-aware 的速度账
类型感知规则要求 parser 挂上完整的 TS Program,冷启动秒级起步,monorepo 里能到分钟级。两条路线通常一起用:拆 job——快速 lint(不带类型信息)挂在每次 PR,type-aware 的完整检查放进合并前队列或每日任务,报错同样阻断;分层放宽——不是所有目录都值得同一套严格度。
{
// 测试代码:fixture 与 mock 上 no-unsafe-* 噪音远大于收益
files: ['tests/**/*.ts'],
rules: {
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
// 生成代码:disableTypeChecked 展开为关闭全部类型感知规则,官方姿势
files: ['src/generated/**/*.ts'],
...tseslint.configs.disableTypeChecked,
},
常见坑点
project 与 tsconfig 对不上,规则会静默失效
最阴险的一类失效。文件被 lint 到、但不在任何 tsconfig 的 include 里时,较新的 typescript-eslint 会报显式 Parsing error(parserOptions.project has been set ... does not match),这是好事。
真正静默失效的是另一半:tsconfig 的 include 改动漏掉某个目录,带规则的配置块 files glob 没覆盖新增的 .tsx、.mts,或 override 把文件分给了不带 project 的配置块——规则对这些文件根本没生效,CI 依旧全绿。验证要写进例行流程,每次调整 include 或 files 后跑一遍:
# 确认规则真的落在某个角落文件上
npx eslint --print-config src/features/checkout/internal/pricing.ts \
| jq '."rules"["@typescript-eslint/no-floating-promises"]'
# 期望输出 ["error"];输出 null 就是静默失效
# 兜底:故意在一个角落文件写一个 floating promise,确认 CI 会红
一次性全开 strict,一周后基线爆炸
把 recommendedTypeChecked 全量开到存量百万行的代码上,报错数千条,团队的应对方式可想而知:巨量 disable 一把梭,契约从"保护边界"退化成背景噪音。落地节奏应该是:新规则只对增量代码生效(CI 只检查 diff 涉及的文件),存量进基线,随清理 MR 递减。
总结
lint 契约的价值不在规则数量,而在三条边界上的事故是否被真正拦住:异步边界拦 floating promise,类型边界把 any 关进解析层,模块边界让 feature 只认 index。例外有原因、有账本、只减不增;并且定期验证规则真的在跑——包括那些你以为在跑的。
没有跑在 CI 里的约定,只是 code review 会议室里的一次点头。