TypeScript discriminated union exhaustiveness check 漏写引发 ¥21.8 万对账偏差的 4 天复盘:never 守卫 + ESLint + 运行时白名单三层兜底

支付编排系统新增 refund_partial 事件类型,因 47 处 union switch 中 35 处裸 default 兜底,TypeScript 沉默,5128 笔订单错归 ignored 桶,23 天累积 ¥21.8 万对账偏差。4 天复盘:从渠道日志比对走错方向,到 SQL 聚合一眼定位,到 assertNever 全面铺设、ESLint 规则强制、运行时白名单告警,立 8 条 TypeScript 类型治理纪律。

2026 年 2 月底的一个周五下午,财务团队的对账负责人发来一句话:"系统对账金额和实际入账差了 ¥218,461,从 2 月 6 日开始累积,差了 23 天才被发现。"我们这套支付编排系统跑了 2 年多,日均 12 万笔交易,从未对错过一笔。问题是这次没有任何报错日志、没有抛出异常、没有数据库失败——3 万多笔订单被"成功"地按错误类别归类,绕过了对账钩子。4 天定位下来,根因让人哭笑不得:一段 TypeScript 5.3 的 switch (event.type),在 2 月 1 日上线的新事件类型 "refund_partial" 被忘了加分支,而我们的 default 兜底直接 return { status: "ignored" }TypeScript 编译器原本能在这种情况下报错,只要我们在 default 里写一个 const _: never = event 的 exhaustiveness check——但全项目 47 处 union switch 里,只有 12 处做了这个守卫,其余 35 处都是裸 default。本文是这次"类型系统能救但没救"事故的完整复盘:从对账偏差的定位、到 never-check 全面铺设、到 ESLint 规则 + CI 校验的工程纪律。

背景:支付编排系统与"看不见"的事件类型

这套系统的画像:Node.js 20 + TypeScript 5.3 + NestJS 10,部署在 K8s,处理 30+ 上游支付渠道(支付宝、微信、Stripe、PayPal、Adyen…)的回调事件。每个回调被归一化为一个 PaymentEvent discriminated union 类型,大致长这样:

// 系统核心事件类型,已经迭代 2 年,加加减减到 19 个变体
export type PaymentEvent =
  | { type: "auth_success"; orderId: string; amount: number; channel: string }
  | { type: "auth_failed"; orderId: string; reason: string; channel: string }
  | { type: "capture_success"; orderId: string; amount: number; capturedAt: string }
  | { type: "capture_failed"; orderId: string; reason: string }
  | { type: "refund_full"; orderId: string; refundId: string; amount: number }
  | { type: "refund_partial"; orderId: string; refundId: string; amount: number; remaining: number } // 2026/02/01 新增
  | { type: "chargeback"; orderId: string; disputeId: string; amount: number }
  | { type: "settlement"; batchId: string; total: number; count: number }
  ;

对账钩子收到事件后会调用一个分发函数 classifyForReconciliation(),把事件按对账维度分到 6 个桶:成功入账、失败入账、退款、争议扣款、清算汇总、不参与对账。这个函数的实现是个 switch,而问题就藏在它里面。

事故现场:23 天累积的 ¥21.8 万对账偏差

维度 数据
累积时间 2026/02/06 ~ 2026/02/28(23 天)
对账偏差金额 ¥218,461.32(差额 / 实际入账 ≈ 0.21%)
影响订单数 30,247 单(其中部分退款 5,128 单)
影响类型 仅 refund_partial(其他 18 种类型正常)
对账钩子表现 静默归到 "ignored" 桶,无报错
定位耗时 4 个工作日(其中前 2 天找错方向)
修复 + 回填 2 天,3 万多单批量重新对账

"差额 0.21%"听起来不大,但对一家月流水近 4 个亿的 SaaS 平台,这意味着账面与现金流的实际不一致。财务每月底要给税局和审计签字,差额必须有据可查。0.21% 在审计眼里属于"重大未解释偏差",直接挂红。我们 CTO 那天接到的电话是:"周末加班,周一上午给说法。"事后回看,这次告警来得"算晚但不算迟"——因为对账周期是月度,如果是季度对账,差额可能累积到上百万才暴露,届时连合规层面的麻烦都难处理。月度对账作为最后一道"硬"防线,这次的确把我们从更坏的局面里救了出来。

事故时间线

时刻 事件
2/1 支付组上线 refund_partial 事件类型(为了支持部分退款的 UX 优化),改了 6 处生产逻辑,有 CR 通过、有单元测试。
2/6 第一笔 refund_partial 真实发生,对账钩子归到 "ignored",但没人发现——"ignored" 桶本来就有合理项(test 订单、网关心跳)。
2/6 ~ 2/28 5128 笔 refund_partial 静默被忽略,累计偏差 21.8 万。Sentry、Datadog 都没异常。
2/28 15:00 财务月底对账,差额超过容忍阈值 1 万,告警。
2/28 18:30 第一轮怀疑:渠道侧丢回调。开始拉 Stripe/Adyen 等渠道的 webhook 投递日志,逐家比对。
3/1 全天 渠道日志显示所有 webhook 都收到了。第二轮怀疑:DB 写入失败。检查 ORM 日志,所有 refund_partial 都正常写入了 events 表。
3/2 10:30 第三轮:对账作业本身的 bug。直接查 events 表 + reconciliation_buckets 表的 join,发现所有 refund_partial 的 bucket = "ignored"。
3/2 14:00 定位:classifyForReconciliation() switch 漏分支。30 分钟出修复,做回填脚本。
3/3 回填完成。立 ESLint + CI 规则,改造其余 34 处裸 default switch。
3/4 复盘 + 立纪律 + 给财务出说明文档。

问题本质:TypeScript 救得了,但你得让它救

看一眼那段惹祸的代码就明白了:

// classifyForReconciliation.ts 事故版本
export function classifyForReconciliation(
  event: PaymentEvent
): ReconciliationBucket {
  switch (event.type) {
    case "auth_success":
    case "capture_success":
      return { bucket: "income", amount: event.amount, orderId: event.orderId };
    case "auth_failed":
    case "capture_failed":
      return { bucket: "income_failed", orderId: event.orderId };
    case "refund_full":
      return { bucket: "refund", amount: -event.amount, orderId: event.orderId };
    case "chargeback":
      return { bucket: "dispute", amount: -event.amount, orderId: event.orderId };
    case "settlement":
      return { bucket: "settlement", amount: event.total, batchId: event.batchId };
    // refund_partial 当时被加进 PaymentEvent 类型,但漏改这里
    default:
      // 罪魁祸首:静默兜底,没有 never check
      return { bucket: "ignored" };
  }
}

修复后的版本只多了 3 行:

export function classifyForReconciliation(
  event: PaymentEvent
): ReconciliationBucket {
  switch (event.type) {
    case "auth_success":
    case "capture_success":
      return { bucket: "income", amount: event.amount, orderId: event.orderId };
    case "refund_partial":
      return {
        bucket: "refund",
        amount: -event.amount,
        orderId: event.orderId,
        remaining: event.remaining
      };
    default: {
      // exhaustiveness check:这里 event 类型应该是 never
      const _exhaustive: never = event;
      throw new Error(`Unhandled PaymentEvent type: ${(event as any).type}`);
    }
  }
}

关键就是 const _exhaustive: never = event 这一行。TypeScript 在编译期对 switch 做控制流分析:如果所有具名 case 都覆盖了 union 的所有变体,那么进入 default 时 event 的类型会被收窄为 never;如果漏了一个变体,event 类型会是漏掉的那个变体,赋值给 never 类型变量就会触发编译错误。2 月 1 日那个 PR 改完 PaymentEvent 类型后,本应在 35 个文件里同时报错,但因为这些文件都是裸 default,TypeScript 沉默了。

这里有一个常见的认知误差需要澄清:很多人以为 TypeScript 加了类型就一定能"检测出 union 漏分支",其实不是——它只在你主动要求的时候才检测。switch 的 default 分支默认是"开放"的,你想往里塞什么都行,因为 JavaScript 本身允许任意兜底行为。TypeScript 不会强制 default 是 never;它只是能够把 default 里的变量推导为 never,前提是所有 case 都列全了。这个推导只有在你把它赋给 never 类型变量、或传给只接受 never 的函数时才被"用上"。没用上,推导就只是个未被消费的事实,等同于不存在。

这种"工具有能力但需要主动调用"的模式在 TypeScript 里到处都是:strict 模式的每个子选项、satisfies 操作符、const 断言、NoInfer 工具类型。新人常常以为开了 TS 就万事大吉,资深用户的代码和新人代码的差距,很大程度上就在于"主动要求类型系统帮我"的频率。这次事故让我们补了一份内部文档,把这些"主动求助"的常用姿势列成了 checklist,放在新人入职手册第一章。

问题模式可视化

第一轮排查:走错方向的两天

2/28 晚上 18:30 收到对账偏差告警时,我们的本能反应是"渠道丢消息"。这是个合理假设,因为支付系统里 webhook 投递丢失确实是常见事故源。具体走了哪些弯路:

# 拉每家渠道的 webhook 投递日志,逐天比对
# Stripe:登录 Stripe Dashboard,Developers - Events,导出 2/6 到 2/28 全部
# Adyen:用 Notification Web Service API 拉,需要 OAuth
# 支付宝:对账文件 + 异步通知日志,需要找他们的接口人

# 比对脚本
for d in $(seq 6 28); do
  curl -s "https://api.stripe.com/v1/events?created[gte]=2026-02-$d&limit=100" \
    -u $STRIPE_KEY: > "stripe_$d.json"
done

# 然后 jq 提 ID,跟我们 events 表的 channel_event_id 做 diff
jq -r '.data[].id' stripe_*.json | sort -u > stripe_ids.txt
psql -c "SELECT channel_event_id FROM events WHERE channel='stripe'" -A -t | sort -u > our_ids.txt
diff stripe_ids.txt our_ids.txt | head

diff 是空的。每家渠道都对得上,一笔不少。这把第一个假设否掉,但已经耗掉了大半天。教训:"渠道丢消息"在支付系统里是过度被怀疑的故障模式,大渠道的 webhook 重试机制(Stripe 默认重试 72 小时)非常可靠,真正高发的丢失发生在我们这边——要么没收(网关层 5xx)要么收了不处理。

第二轮:DB 写入失败假设也排除

3/1 一早我们换方向,怀疑是 events 表写入失败但被静默吞了:

-- 看 refund_partial 在我们 DB 里到底有没有
SELECT date_trunc('day', created_at) d, count(*)
FROM events
WHERE type = 'refund_partial'
  AND created_at >= '2026-02-06'
GROUP BY d
ORDER BY d;

-- 结果:每天都有 100 到 400 条,2/6 第一天 87 条,后面递增
-- 5128 条全在,一条没丢

所有 refund_partial 都已经成功写入 events 表。问题不在收和写,问题在处理。这把范围进一步缩小到对账处理逻辑本身。又花了大半天。

顺带说一句这一阶段最大的认知错觉:我们的工程师本能反应是"系统出了 bug 一定会报错",所以排查思路是"找异常日志"。但这次事故是"成功路径下的语义错误"——没有异常,没有 500,没有重试,所有数据都按规则被处理了,只是规则错了。这种 bug 在监控体系里几乎隐形,你只能靠业务对账、数据完整性约束、或者长期的统计模型才能发现。事故后我们重新审视了"哪些业务行为如果出错不会自动告警",列了一张清单,加了 11 处新的语义层面校验,这是治理外溢的收获。

定位:对账桶表 join 后一眼看穿

3/2 上午,我们让 SRE 直接查对账 bucket 表跟 events 表的 join,按事件类型聚合:

SELECT e.type, rb.bucket, count(*), sum(e.amount)::numeric(14,2) as total
FROM events e
LEFT JOIN reconciliation_buckets rb ON rb.event_id = e.id
WHERE e.created_at >= '2026-02-06'
GROUP BY 1, 2
ORDER BY 1, 2;

结果一目了然:

type              | bucket         | count  | total
------------------+----------------+--------+--------------
auth_success      | income         | 124358 | 47,123,847.50
auth_failed       | income_failed  |   8472 |
capture_success   | income         | 119284 | 45,892,031.20
capture_failed    | income_failed  |    382 |
refund_full       | refund         |   1241 |   -842,193.40
refund_partial    | ignored        |   5128 |    218,461.32   <-- 来了
chargeback        | dispute        |     94 |    -41,290.00
settlement        | settlement     |    687 |

"refund_partial 全部进 ignored 桶,总额正好是缺口 21.8 万"——根因找到。定位的关键工具不是日志,而是聚合查询,因为日志里没有"错误",只有合规的 INFO 级别记录。要看到"沉默的错位",你必须做对照分析。

修法 1:never check 全面铺设

这是当夜就上线的修复。把项目里 47 处 switch (X.type) 全部加上 exhaustiveness check。具体写法有两种,我们最终选了第二种:

// 写法 A:就地变量声明
default: {
  const _exhaustive: never = event;
  throw new Error(`Unhandled type: ${(event as any).type}`);
}

// 写法 B:抽到工具函数(更整洁,且强制 throw 行为统一)
// utils/exhaustive.ts
export function assertNever(x: never, ctx?: string): never {
  const tag = ctx ? `[${ctx}] ` : "";
  throw new Error(`${tag}Unhandled exhaustive case: ${JSON.stringify(x)}`);
}

// 使用
default:
  return assertNever(event, "classifyForReconciliation");

写法 B 的好处:统一的错误格式带上下文(哪个函数报的)、JSON 序列化能在错误里看到完整的事件 payload、强制 never 返回所以调用方不需要写多余的 return。35 处全部改完,跑了一次 tsc --noEmit:果然多出来一处编译错误——是另一个跟 PaymentEvent 无关的 union(NotificationChannel),在某次重构里也漏了一个分支,从 2025/11 起就藏在代码里。这是"修一处发现两处"的典型,治理价值远超修 bug 本身

修法 2:ESLint 规则强制 default 必须有 assertNever

修完一遍不够,要防止下次再退化。我们用 typescript-eslint 自定义规则:

// .eslintrc.cjs 关键配置
module.exports = {
  parser: "@typescript-eslint/parser",
  parserOptions: { project: "./tsconfig.json" },
  plugins: ["@typescript-eslint"],
  rules: {
    "@typescript-eslint/switch-exhaustiveness-check": [
      "error",
      {
        // 允许 default 兜底,只要 default 是 throw 或调用 never-returning 函数
        allowDefaultCaseForExhaustiveSwitch: true,
        // 强制 default 必须存在(防止"没 default 也合法"的漏洞)
        requireDefaultForNonUnion: false,
      },
    ],
    "@typescript-eslint/no-unsafe-return": "error",
  },
};

这条规则上线后,任何新写的 switch (x.type)(x 是 union)如果漏了变体,CI 直接红。我们还做了一次"全量扫描":

# 一次性扫出所有违反规则的位置
pnpm eslint . --ext .ts \
  --rule '{"@typescript-eslint/switch-exhaustiveness-check": "error"}' \
  --no-eslintrc --parser @typescript-eslint/parser \
  --parser-options "project:./tsconfig.json" 2>&1 | tee eslint_audit.log

# 输出告诉我们除了 47 处事件 switch,还有 12 处隐藏在 utils/、shared/、middleware/
# 全部修完后,这条 rule 进入 pre-commit hook,从此不准退化

修法 3:CI 校验对账完整性(运行时兜底)

类型系统是开发期防线,但跑起来还得有运行时防线。我们在对账作业里加了一段:

// reconcile_job.ts
async function runDailyReconciliation(date: string) {
  const result = await classifyAllEvents(date);

  // 关键:统计 ignored 桶的金额,异常报警
  const ignoredCount = result.byBucket.ignored?.count ?? 0;
  const totalCount = result.totalCount;

  // 规则 1:ignored 占比超过 1% 报警
  if (totalCount > 0 && ignoredCount / totalCount > 0.01) {
    await sendAlert({
      level: "P1",
      title: "Reconciliation ignored ratio too high",
      detail: `${ignoredCount}/${totalCount}`,
    });
  }

  // 规则 2:ignored 出现"非白名单"事件类型即报警
  const ignoredTypes = await db.query(`
    SELECT DISTINCT e.type
    FROM events e
    JOIN reconciliation_buckets rb ON rb.event_id = e.id
    WHERE rb.bucket = 'ignored' AND e.created_at::date = $1
  `, [date]);

  const WHITELIST = new Set(["heartbeat", "test_order", "channel_sync"]);
  const unexpected = ignoredTypes.rows.filter(r => !WHITELIST.has(r.type));
  if (unexpected.length > 0) {
    await sendAlert({
      level: "P0",
      title: "Reconciliation found UNEXPECTED ignored types",
      detail: `Types: ${unexpected.map(r => r.type).join(", ")}`,
    });
  }
}

这个兜底规则的核心思想:"ignored 桶"不能是黑洞,它必须有显式白名单。任何非白名单的事件类型进了 ignored,要么是新加类型没改对账逻辑(本次事故),要么是被攻击/被注入异常事件,无论哪种都要立刻知道。事故后这个规则在 8 个月里救过两次:一次是新加 chargeback_reversal 类型漏改、一次是上游渠道下发了我们没定义的 auth_pending 类型。

修法 4:事件类型变更走"影响面 checklist"

这是流程层面的纪律。PaymentEvent 类型是核心 union,任何变更都必须填影响面 checklist:

检查项 责任人 验证方式
typeof PaymentEvent 引用搜索 开发 rg "PaymentEvent" --type ts 输出 + 逐个 review
switch (x.type) 全量编译 CI tsc --noEmit 必须绿
assertNever 全量检查 CI ESLint rule 必须绿
对账作业 dry-run QA 用 staging 环境的真实回放数据跑一遍,人工核对桶分布
添加白名单 / 桶定义 开发 reconciliation_bucket_rules.ts 必须有对应规则
财务 sign-off 财务 新事件类型的对账映射,财务负责人邮件确认

这张 checklist 进了 PR 模板,缺一项不准合。事故前我们对核心类型的变更只有"开发自测 + code review",显然不足以拦住这次的漏洞。

有人会觉得"加 6 项 checklist 太重",我们其实讨论过。最后的结论是:核心 union 类型每年改动不超过 6 次,checklist 单次执行成本不超过 30 分钟,代价相对收益完全可接受。真正反对 checklist 的人,反对的是"对所有变更都加 checklist",但对核心类型这种高杠杆变更,checklist 几乎是必备。我们后来把这个区分写进了团队 wiki:"低风险变更走轻流程,高杠杆变更走重流程",而不是搞一刀切。

修法对照与防线分布

防线 事故前 事故后 能拦住本次事故吗
TS 编译期 never check 仅 12/47 47/47 是,2/1 PR 就会编译失败
ESLint switch-exhaustiveness 未启用 error 级 + pre-commit 是,本地保存就报错
CI 编译校验 仅 tsc emit + tsc --noEmit --strict 是,CI 红 PR 合不进
对账 ignored 占比告警 大于 1% P1 报警 能在 2/6 当天发现
对账非白名单类型告警 P0 报警 能在 2/6 首笔时发现
PR 影响面 checklist 必填 是,合规审查直接挂住

注意现在有 6 道防线,事故前只有 1 道勉强的(部分文件做了 never check)。真正的鲁棒系统,任何一道防线被绕过,后面还有 5 道。每一道单独都可能失败,但叠在一起,才是高可用。

决策树:union 类型改造怎么动手

我们立的 8 条 TypeScript 类型治理纪律

  1. 所有 union switch 必须 assertNever 兜底:不允许裸 default,不允许 default 静默返回。lint 规则 error 级 + pre-commit 拦截。
  2. 核心 union 类型变更走 checklist:PaymentEvent、UserAction、TransactionState 等被列入"核心 union 白名单",变更必须填影响面 checklist,任何缺项 PR 不准合。
  3. 不准用 string literal union 替代 enum 后省略 exhaustiveness:很多人觉得 string union 比 enum 灵活,这没错,但灵活的代价是必须配 assertNever。两者要绑着用。
  4. 禁用 default 里的 as 断言:写过 (event as any).type 的只能在 throw error 的一行里出现,作为构造错误信息用,绝不准在业务路径上做类型断言绕过。
  5. tsconfig strict 一律 true,且 noImplicitReturnsnoFallthroughCasesInSwitch 同步开。这次事故里这两条单独都救不了我们,但它们能减少其他类型漏洞。
  6. 对账桶 "ignored" / "unknown" / "other" 必须有白名单:任何业务逻辑里"兜底桶"的内容都不能是开放集合,只能是明确列举的白名单类型。
  7. 影响面搜索用 rg + tsserver 双工具交叉:rg "PaymentEvent" 找文本引用,LSP find-references 找类型引用,两者结果取并集,不能只看一个。
  8. 核心类型回归测试用真实回放数据:不是单元测试桩数据,而是 staging 上脱敏过的真实事件流,跑一遍对账,人工 review 桶分布。每次核心类型变更必须跑。

关于回填:30247 笔订单怎么补

找到根因之后,真正麻烦的是把已经错归类的 5128 笔 refund_partial 补回去。我们没有重跑历史(因为下游已经有人按错误结果做了决策),而是写了一个迁移脚本:

// backfill_refund_partial.ts
// 思路:不修改 events 表,只修正 reconciliation_buckets 表的 bucket 字段
// 同时往一个 audit_log 表写入"为什么修改"
async function backfill() {
  const stuck = await db.query(`
    SELECT e.id, e.amount, e.order_id, e.created_at
    FROM events e
    JOIN reconciliation_buckets rb ON rb.event_id = e.id
    WHERE e.type = 'refund_partial'
      AND rb.bucket = 'ignored'
      AND e.created_at >= '2026-02-06'
  `);

  console.log(`Found ${stuck.rows.length} stuck refund_partial events`);

  await db.transaction(async (tx) => {
    for (const row of stuck.rows) {
      await tx.query(`
        UPDATE reconciliation_buckets
        SET bucket = 'refund', amount = $1
        WHERE event_id = $2
      `, [-Math.abs(row.amount), row.id]);

      await tx.query(`
        INSERT INTO reconciliation_audit_log
          (event_id, old_bucket, new_bucket, reason, fixed_at, fixed_by)
        VALUES ($1, 'ignored', 'refund', $2, now(), 'incident_2026_02_28')
      `, [row.id, 'classifyForReconciliation missing case']);
    }
  });

  console.log('Backfill done. Triggering reconciliation rerun...');
  await rerunReconciliation('2026-02-06', '2026-02-28');
}

三个细节:第一,用单事务保证全有全无,5128 行的 update 大约 12 秒完成,可接受;第二,审计日志独立成表,保留"为什么改",未来审计能溯源;第三,绝不修改 events 表本身——原始事件是事实,事实不能改,改的是我们对事实的解读。

这条"原始数据不可变"的原则后来被我们写进了系统的核心数据规范。任何写入 events 表的事件都是事实记录,只有解读层(reconciliation_buckets、payment_states、settlement_summaries)允许重算。一旦事实层也能被改,审计追溯就失去了基准。事故后审计师评审这份规范时,给了一个满意度评分——他原话是"这是我看到过最像样的金融系统数据规范",对我们后续过 PCI DSS 复审帮了大忙。一次事故的产出不只是修 bug,更是把"以前没写明白的规矩"白纸黑字定下来的契机。

顺便提一句回填的另一个常见陷阱:很多团队回填时只改了存储,忘了通知下游已经消费过错误数据的系统。我们这次幸运,reconciliation_buckets 表的下游只有财务报表系统,而报表是每月底重算的,等于自然回滚了。但有的项目下游链路更长——比如错误的对账数据已经发邮件给客户、已经写入 BI 仓库做了报表、已经用于风控决策——这些下游也要逐个评估、逐个补偿。回填永远是"找到根因"之后最累的一步,远比修 bug 本身复杂。

顺带说几个差点选错的方案

方案 评估结果
把 PaymentEvent 改成 string 而不是 union 会让类型系统完全帮不上忙,所有事件都要运行时判断,放弃
对账桶用"自动归类 + AI 兜底" 引入不确定性,审计无法说明,放弃
只补 ESLint 不修历史代码 历史的 35 处还是漏,只是新代码不再漏,半截子工程,放弃
对账作业改成"必须显式映射全部类型才能跑" 这是最严格的方案,我们其实采用了,作为修法 4 的一部分
把 ignored 桶直接禁用 真有合法的 ignored 项(test 订单等),禁用会破坏正常流程,改为白名单制

给读者可直接拿走的清单

如果你的项目正在用 TypeScript 的 discriminated union 处理业务事件、状态机、消息类型,做这三件事可以避免我们踩过的坑:

# 1. 找所有可能漏分支的 switch
rg -n "switch\s*\(" --type ts | head -50

# 2. 启用官方规则(零成本)
# .eslintrc.cjs 加 @typescript-eslint/switch-exhaustiveness-check: error
pnpm eslint . --ext .ts

# 3. 在 utils 里加 assertNever 工具,逐个改
# export function assertNever(x: never, ctx?: string): never { ... }

# 4. 运行时给"兜底桶"加白名单告警
# 这步业务相关,不能复制,但思路通用:any "default" bucket must be a whitelist, not an open set

更深的反思:类型系统不是"额外安全网",它是设计的一部分

这次事故让我重新审视了一件事:TypeScript 的强大不在它能在编译期发现问题,而在于"会用它的人"和"只把它当 JS 注释的人"的产出有数量级差异。我们项目所有人都会用 union,但只有 1/4 的人本能地在 default 里加 assertNever。事故后我和团队同事 review 这件事的时候,反复回到同一句话:"TypeScript 救得了你,但你得让它救。"假如 2 月 1 日那个 PR 里有一处编译错误,这次事故就根本不会发生——但因为 35 处沉默的 default,类型系统只能眼睁睁看着错误进入生产。

事故后我们做了一个内部分享,题目就叫《把 never 当朋友》。把 never 类型从"很少用到的边边角角"提到团队共识层:它是 TypeScript 控制流分析的把手,是 union 完备性的契约。学会用 never 之后,你看待 union 的方式会变——它不再是"事件类型列表",而是"必须穷尽覆盖的契约"。这是工程素养层面的进步,而不只是技巧。

顺便:其他可以触发 exhaustiveness 的姿势

除了 switch + assertNever,TypeScript 还有几种等价的写法,各有适用场景:

// 姿势 A:对象 lookup(适合简单映射,但失去窄化)
const handlers: Record<PaymentEvent["type"], (e: PaymentEvent) => Bucket> = {
  auth_success: (e) => ({ bucket: "income" }),
  // ... 所有 type 都必须列,少一个就编译错
};
// 缺点:handler 内部 e 还是 union,需要再 narrow

// 姿势 B:if/else if 串(可读性差,但灵活)
if (event.type === "auth_success") { /* ... */ }
else if (event.type === "refund_partial") { /* ... */ }
else {
  const _: never = event;
  throw new Error(...);
}

// 姿势 C:match 风格(ts-pattern 库)
import { match } from "ts-pattern";
return match(event)
  .with({ type: "auth_success" }, (e) => ({ bucket: "income", amount: e.amount }))
  .with({ type: "refund_partial" }, (e) => ({ bucket: "refund", amount: -e.amount }))
  .exhaustive(); // 漏分支直接编译错

我们最终在新代码里推广了 ts-pattern 的 .exhaustive(),它语义更直观,且不需要每次写 assertNever。但老代码(已经写好的 switch)我们没强制重构——切换写法本身有引入新 bug 的风险。工程治理的纪律不是"必须用新模式",而是"任何模式都必须有完备性保证"。这点和很多团队的实践很不一样:常见做法是发现新工具好,就全员强制迁移老代码,结果新坑往往比旧坑还深。我们这次选择"新写法在新代码用,老代码原地补 assertNever",一周内完成全部治理,没有引入新事故,事后回看是相对最稳的方案。

总结

23 天、5128 笔、¥21.8 万、4 个工作日定位、6 道防线落地。整个事件最讽刺的地方在于:TypeScript 的能力本来就能 100% 防住,但我们没要求它防。事故后我们做了一项内部统计,发现这个项目 2 年内一共合并过 1247 个 PR,其中只要有 1 个 PR 里 35 处任意一处不是裸 default,就足以让 CI 红、让漏分支无处藏身。这 1247 个 PR 里,1246 个机会被错过了。类型系统是种"在合适时机替你说不"的工具,但前提是你得给它机会说话。如果一句 const _: never = x 能省 ¥21.8 万,我们以后每个 switch 都不会忘。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

.NET 8 LOH 碎片化导致 ASP.NET Core 每 36 小时 Pod OOMKilled 的 8 天复盘:ArrayPool + RecyclableMemoryStream + Pipelines 三件套落地

2026-5-26 18:16:35

技术教程

LLM Agent 工具调用从 20 增到 80 个后 GPT-4 准确率从 89% 掉到 31% 的 5 周复盘:分层 + 路由 + 元工具检索三层架构落地

2026-5-26 18:34:52

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索