一次"无害"的 minor 发布

内部库 @acme/api-client 发了 3.2.0,changelog 只有一行:“User 新增可选字段 avatarUrl”。半小时后,三个下游仓库的 CI 全红。肇事代码长这样:

import type { User } from '@acme/api-client'

// 下游用 keyof 驱动表格列配置——完全合法的用法
const columnLabels: Record<keyof User, string> = {
  id: 'ID',
  name: '姓名',
}

加了一个可选字段后,keyof User 多出了 avatarUrl,而 Record 要求每个 key 都出现,TS 直接报缺属性。“我们没改任何函数签名”——没用。对类型系统来说,公共类型的结构本身就是接口:任何会改变下游编译结果的变更都是破坏性变更,哪怕它"只是加了个可选字段"。

类型即契约:五类看着无害的破坏性变更

// 1) 可选 -> 必填:下游所有构造点立刻缺字段
interface TaskV1 { id: string; title?: string }
interface TaskV2 { id: string; title: string }

// 2) 联合收窄:昨天能传任意 string,今天不能了
type StatusV1 = string
type StatusV2 = 'active' | 'frozen'

// 3) 导出改名:import { Task } 直接红
export { type TaskV2 as Task }

// 4) 字面量 widening:没写显式返回类型的导出函数尤其危险
function envV1() { return 'prod' as const }   // 'prod'
function envV2() { return getEnvName() }      // string,实现一改推断就变
// 下游 const e: 'prod' = env() 在 v2 下编译失败

// 5) keyof / 映射类型的连锁反应:本篇开头的事故
type Labels = Record<keyof User, string>

这五类的共同点:没改运行时代码一行,破坏却真实发生。所以判断标准不是"我改了多大的 diff",而是"下游的编译结果会不会变"。这个判断靠自觉不靠谱,得靠工具。

把公共 API 面变成可评审的产物:api-extractor 进 CI

api-extractor 从构建产物入口扫描类型声明,生成一份 .api.md 报告。把它提交进仓库,类型的任何增删都会体现为这个文件的 diff。

npm i -D @microsoft/api-extractor
npx api-extractor init     # 生成 api-extractor.json
{
  "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts",
  "apiReport": {
    "enabled": true,
    "reportFolder": "<projectFolder>/etc/",
    "reportTempFolder": "<projectFolder>/etc/temp/"
  },
  "dtsRollup": {
    "enabled": true,
    "untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>.d.ts"
  }
}
# 本地:生成或更新 .api.md,随代码一起提交
npx api-extractor run --local

# CI:不带 --local,.api.md 与产物不一致时直接失败
npx api-extractor run

之后每个动到公共类型的 PR 都会在 etc/api-client.api.md 的 diff 里显形:新增了什么成员、标注为 Released 还是 Beta、删掉了什么导出,评审一眼可见。开篇那种事故,在 PR 里就是一行 avatarUrl: string; 的 diff,评审人有机会在发布前拦住。不想引入 api-extractor 的等价做法:构建后对入口 .d.ts 做快照 diff;attw 可以互补检查包的 exports 结构,但覆盖不了类型层面的变更。

真要变:向后兼容的扩展手法

// 新增可选字段:对绝大多数下游兼容,除了 keyof 映射(见第五类)
export interface Task {
  id: string
  retries?: number          // 新增
}

// 加函数能力:新增重载放前面,旧签名原样保留
export function getTask(id: string): Promise<Task>
export function getTask(ids: string[]): Promise<Task[]>
export function getTask(idOrIds: string | string[]): Promise<Task | Task[]> {
  return typeof idOrIds === 'string'
    ? http.get(`/tasks/${idOrIds}`)
    : Promise.all(idOrIds.map((id) => http.get(`/tasks/${id}`)))
}

discriminated union 加成员要小心:对做了穷尽检查的下游,这就是破坏性变更。

type FetchEvent =
  | { type: 'start'; url: string }
  | { type: 'success'; ms: number }

function log(e: FetchEvent) {
  switch (e.type) {
    case 'start': return
    case 'success': return
    default: {
      const _never: never = e   // v2 给联合加 { type: 'abort' } 后这行报错
      return _never
    }
  }
}

所以要么接受"加成员 = 半破坏"并配合弃用期通报,要么用新名字导出新的联合,旧的保持冻结。

要下线旧导出时,用 @deprecated 把意图写进类型,再让 lint 把它变成硬约束:

export interface Task {
  /** @deprecated 3.4 起改用 retries,弃用窗口两个 minor,v4.0 移除 */
  retryCount?: number
  retries?: number
}
// eslint.config.js —— typescript-eslint v8 起内置 no-deprecated(需要类型信息)
import tseslint from 'typescript-eslint'

export default tseslint.config(
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: { parserOptions: { projectService: true } },
    rules: { '@typescript-eslint/no-deprecated': 'error' },
  },
)

自家 monorepo 可以直接 error 强推;对外发布的库,把配置示例写进迁移文档,让下游自己定 severity。

类型与运行时各说各话的坑

类型的破坏性变更看得见,类型和运行时的不一致才是暗伤:类型写 status: 'active' | 'frozen' 而后端实际还会返回 'banned',脏数据带着"类型保证"流遍全应用;类型写 id?: string 而运行时必传,下游被类型误导省略参数,然后炸在半夜。让 schema 成为唯一事实来源,类型从 schema 推导,并在边界强制校验:

import { z } from 'zod'

export const UserSchema = z.object({
  id: z.string(),
  status: z.enum(['active', 'frozen']),   // 运行时真的只会是这两个值
})
export type User = z.infer<typeof UserSchema>

// 边界处 parse:契约不一致就在门口抛,别让脏数据进类型系统
export async function getUser(id: string): Promise<User> {
  const res = await http.get(`/users/${id}`)
  return UserSchema.parse(res.data)
}

再加一层类型层面的回归测试,把"契约此刻长什么样"钉死在 CI:

import { expectTypeOf } from 'expect-type'

// 有人把 status 放宽回 string 或收窄成单值,这条测试立刻失败
expectTypeOf<User['status']>().toEqualTypeOf<'active' | 'frozen'>()

删除要有流程:统计、codemod、弃用窗口

弃用窗口的节奏:minor N 引入 @deprecated 与替代品,minor N+1 下发 codemod、通知下游开 lint,调用曲线归零后,下一个 major 或约定的 minor 删除。两个 minor 是下限,重灾区给三个。

// codemods/rename-retryCount.cjs —— jscodeshift 批量迁移下游代码
module.exports = function transformer(file, api) {
  const j = api.jscodeshift
  return j(file.source)
    .find(j.Identifier, { name: 'retryCount' })
    .forEach((p) => j(p).replaceWith(j.identifier('retries')))
    .toSource()
}
npx jscodeshift -t codemods/rename-retryCount.cjs ../admin-console/src

统计别靠问,靠埋点:

/** @deprecated 改用 listTasks,v4.0 移除 */
export function fetchTasks(...args: Parameters<typeof listTasks>) {
  track('deprecated:fetchTasks')   // 上报内部指标,周报看曲线
  return listTasks(...args)
}

“到日子就删"和"永远不敢删"都是失职:前者砍信任,后者让 API 面永远膨胀。曲线是唯一可信的裁判。

实在兼容不了:subpath v2 还是包名 major

{
  "name": "@acme/api-client",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./v2": {
      "types": "./dist/v2/index.d.ts",
      "default": "./dist/v2/index.js"
    }
  }
}

下游 import { getUser } from '@acme/api-client/v2',同一仓库里 v1 与 v2 并存,按页面渐进迁移,迁完统一删 v1 入口。注意 exports 的 types 条件要写全,否则下游 TS 解析不到声明文件;老版本 TS 还需要 typesVersions 兜底。

包名 major(@acme/api-client@4)的问题:npm 一个仓库装不下同名包的两个版本(得靠 alias 安装),适合"全量切换、一步到位”;子路径导出适合"长期共存、按页面迁"。选哪个取决于下游的迁移能力,不是库作者的偏好。

常见坑点

1. 把"编译能过"当"兼容"

下游有运行时 schema 校验(additionalProperties: false 的 JSON Schema、zod strict)时,加字段照样炸。类型报告之外,发布说明要写清运行时影响。

2. @deprecated 只写注释不开 lint

等于贴了张没人看的告示。要么自己推 no-deprecated,要么给下游一份现成配置。

3. 删除导出前不看调用统计

拍脑袋定时间点,删完当天收三个事故工单。

4. 版本化只覆盖类型

URL 语义、header 约定、错误码这些"没进类型的契约"同样要评审与公告,否则类型绿灯挡不住运行时红灯。

总结

  • 公共类型是契约,是否破坏由下游的编译结果定义,不由 diff 大小定义
  • .api.md 进 CI,让每个类型变更都变成可评审的 diff
  • 扩展优先:可选字段、新增重载;变更走弃用窗口 + codemod + 调用统计
  • 类型与运行时用同一个 schema 来源,边界 parse,另加类型级回归测试

类型是你发布给所有调用方的承诺,从发布那一刻起就不只属于你。