一次"无害"的 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,另加类型级回归测试
类型是你发布给所有调用方的承诺,从发布那一刻起就不只属于你。