本节目标:让版本号说实话。读完你会知道哪些类型改动属于破坏性变更(很多是运行时看不出来的)、怎么用 api-extractor 的 API 报告和类型测试把公开契约锁进 CI、怎么用 changesets 组织发布,以及发布破坏性变更时该给用户留出什么样的迁移路径。
7.3 semver、发布与类型破坏性变更
前两节解决了「包能不能被正确消费」。这一节解决「包改了之后,用户会不会突然编译不过」。
semver 的约定大家都背得出:MAJOR.MINOR.PATCH,破坏性变更升 major。但对 TypeScript 库作者来说,判断「什么算破坏性」这件事本身比 semver 规则难得多。原因在于:库的公开契约不只有运行时行为,还有类型签名。而类型签名变化时,你的包自己往往编译得好好的,是用户的代码挂了。
7.3.1 运行时破坏性变更的常识
先把熟悉的说完,作为对照。以下改动运行时层面就是破坏性的:
| 改动 | 影响 |
|---|---|
| 删除导出的函数或类 | 用户 import 直接失败 |
| 必填参数新增、参数顺序调整 | 调用点传参错位 |
| 返回值结构变更 | 用户下游取值失败 |
| 抛出的错误类型变化 | 用户的 catch 分支失效 |
| 最低 Node 版本提升 | 用户运行时崩溃 |
这些都能被运行时测试或用户的集成测试抓到,社区共识也清楚。真正的麻烦在下一节。
7.3.2 类型层的破坏性变更清单
下面这些改动,运行时可能完全正常,但会让用户 tsc 报错。这是 TypeScript 库作者最容易低估的一类:
| 改动 | 为什么破坏 | 用户看到的报错 |
|---|---|---|
| 把参数类型收窄 | 用户传的旧值不再合法 | 类型 'X' 的参数不能赋给类型 'Y' 的参数 |
| 把返回值类型收窄 | 用户依赖了更宽的旧类型 | 赋值不兼容 |
给可选属性去掉 ? | 旧对象字面量缺字段 | 缺少属性 |
| 泛型参数增加约束 | 用户旧的实例化不满足 | 不满足约束 'Y' |
| 泛型参数数量变化 | 显式传参的调用点错位 | 需要 N 个类型参数 |
| 重载签名顺序调整 | 推断结果变了 | 推断出的类型不同 |
| 接口新增必填成员 | 用户实现类不再完整 | 缺少属性 |
把 interface 改成 type(或反之) | 声明合并 / 扩展行为变化 | 不能扩展 |
引入 unique symbol 品牌字段 | 用户手写的等价对象不再匹配 | 不兼容 |
提高 lib / target | 用户环境的类型缺失 | 找不到名称 |
反向的改动通常是安全的:把参数放宽(逆变位置放宽)、把返回值收窄(协变位置收窄)、把必填属性变成可选。这就是著名的**「宽进严出」**——输入类型越宽越兼容,输出类型越窄越兼容。第 1 章讲的结构化兼容性判定就是这条规则的底层依据,可延伸阅读 1.1 结构化类型与兼容性判定 。
7.3.3 为什么这类变更最难发现
看一个具体例子。你的 1.0 版长这样:
export interface Options {
url: string;
retries?: number;
}
export function connect(options: Options): Promise<Connection> {
return doConnect(options);
}
1.1 版你把 url 改成必填的 string(本来就该必填),顺手把 retries 的默认值从 3 改成 5。包自己的测试全绿、运行时完全正常。但用户这段代码:
const opts: Options = { url: "https://api.example.com" };
connect({ ...opts, retries: undefined });
在 1.0 下通过,1.1 下如果开了 exactOptionalPropertyTypes 就会报错。用户升级 patch 版本后编译失败——这是最坏的一类体验。
更隐蔽的是泛型推断的漂移。给一个泛型函数加一个默认类型参数,绝大多数用户无感,但显式写了类型参数的调用点可能推断出不同结果。这类改动没有任何自动化工具能百分百拦住,只能靠契约测试 + 谨慎的发布纪律。
7.3.4 用 API 报告做版本门禁
api-extractor 除了打包声明(上一节),还能生成 API 报告:一份人类可读、可 diff 的公开契约快照。
npx api-extractor run --local
git diff temp/mylib.api.md
生成的 temp/mylib.api.md 里是公开契约的纯文本快照:
// temp/mylib.api.md 片段:可直接 git diff
export interface Options {
retries?: number;
url: string;
}
export function connect(options: Options): Promise<Connection>;
把这份文件提交进仓库,每次 PR 都能看到公开 API 的逐行差异。配合 CI:
- name: API 契约检查
run: |
npx api-extractor run
git diff --exit-code temp/mylib.api.md
git diff --exit-code 在有差异时返回非零,CI 就会红。这不是「禁止改 API」,而是让每次 API 改动都必须显式确认——评审者看到 - retries?: number 变成 + retries: number,立刻会问「这是 major 吗」。
对单文件声明的小包,等效的轻量方案是直接把 dist/index.d.ts 提交一份到 api/ 目录下做快照 diff。工具不重要,**「公开契约必须有可见的 diff」**才是要点。
7.3.5 类型测试:把契约写成可执行断言
API 报告能拦住「签名变了」,但拦不住「行为上等价、类型上不等价」的细微情况,也拦不住「本不该变的变了」。类型测试补上这一环。最轻的做法是手写断言文件:
// test-d/index.test-d.ts
import { connect, type Options } from "mylib";
import { expectTypeOf } from "vitest";
// 契约 1:Options.retries 必须仍为可选
expectTypeOf<Options>().toMatchTypeOf<{ url: string }>();
expectTypeOf<Options["retries"]>().toEqualTypeOf<number | undefined>();
// 契约 2:返回值形状不变
expectTypeOf(connect).returns.toEqualTypeOf<Promise<Connection>>();
// 契约 3:不该暴露的内部类型没有泄漏
expectTypeOf<Parameters<typeof connect>[0]>().not.toBeAny();
配一个只跑类型的测试脚本:
{
"scripts": {
"test:types": "vitest --typecheck --run"
}
}
这类测试的价值在于它把「我不打算改这个」变成了可执行的。当有人不小心把 retries?: number 改成 retries: number,CI 直接失败,并指向那条断言。tsd 或 vitest 的 expectTypeOf 都可以,选团队已有的那个。
把这两层合起来看,一道完整的版本门禁是这样分层的:
| 层级 | 手段 | 拦住什么 |
|---|---|---|
| 契约快照 | api-extractor API 报告 diff | 任何公开签名变化 |
| 行为契约 | 类型测试断言 | 不该变的细微类型漂移 |
| 运行时 | 单元 / 集成测试 | 行为回归 |
| 冒烟 | 基于 npm pack 产物的安装测试 | 发布配置错误 |
7.3.6 changesets:把版本决策写进 PR
版本号不该在发版那一刻拍脑袋决定,而应该在改代码的那个 PR 里就定下来。changesets 是目前最贴合这个流程的工具:
npx changeset
它会问三件事:哪些包受影响、每个包升 major/minor/patch、写一句面向用户的变更说明。答案落成一个 markdown 文件进仓库:
---
"mylib": minor
---
新增 `connectWithRetry` 辅助函数;`connect` 的 `retries` 选项默认值从 3 改为 5。
发版时执行:
npx changeset version
npx changeset publish
version 会消费这些文件、更新 package.json 的版本、生成 CHANGELOG.md;publish 负责打 tag 与推包。好处很实在:版本决策在上下文最充分的时候做出,而不是攒到发版时回看一堆 commit 猜。
在 monorepo 里,changesets 还会自动处理内部依赖的版本联动——A 包升 major 时,依赖 A 的 B 包该不该跟着升,它按配置算。相关的 monorepo 组织方式可延伸阅读 TypeScript Monorepo 与 Turborepo 。
7.3.7 预发布与 dist-tag
破坏性变更不该直接推给所有人。用 dist-tag 做渐进发布:
npm version 2.0.0-beta.1
npm publish --tag beta
用户显式安装才拿到:
npm install mylib@beta
验证一段时间、收集反馈后再提升为正式版:
npm dist-tag add mylib@2.0.0 latest
几个实践要点:
latest是默认 tag。npm publish不带--tag就占latest,这是事故高发点。预发布务必显式写--tag beta/--tag next。- 不要复用已发布的版本号。npm 不允许覆盖,
npm unpublish在 72 小时后基本不可行(且会被镜像站残留)。 prepublishOnly挂钩是最后一道闸:
{
"scripts": {
"prepublishOnly": "npm run build && npm run test:types && npm run test"
}
}
它保证「测试没过就发不出去」,比依赖人的自觉可靠。
7.3.8 破坏性变更的发布礼仪
一旦确定要发 major,怎么发比发什么更重要:
第一,给出迁移说明,而不是只列改动。 「把 retries: number 改成 retries?: number」是改动;「如果你之前写了 retries: undefined,改用省略该字段」才是说明。
第二,能自动化就自动化。 如果你的破坏性变更涉及用户常见写法(比如重命名了一个高频导入名),提供一个 codemod 脚本,用户跑一条命令就能迁移。基于 TypeScript AST 的 codemod 做法是第 6 章的内容,可延伸阅读 6.3 重构工具与 codemod 。
第三,能分两步就不要一步到位。 经典的弃用周期:
/** @deprecated 2.1 起废弃,请改用 `connectWithRetry`,将于 3.0 移除。 */
export function connect(options: Options): Promise<Connection> {
return connectWithRetry(options, 1);
}
@deprecated 标签会让用户在编辑器里看到删除线,IDE 还能配成警告。先在一个 minor 版本里加弃用标记、保留实现,等一个 major 再删——用户有整整一个 minor 周期去处理。这比「2.0 直接删」友好得多。
第四,别把「内部实现变更」写成 major。 每次改动都发 major 会让用户对升级麻木,真正重要的破坏性变更反而被淹没。semver 的价值建立在用户能信任版本号之上。这与 TypeScript 自身大版本升级的策略是同一套逻辑,可延伸阅读 11.1 TS 版本演进与 breaking changes 与 TypeScript 大版本升级 。
7.3.9 常见错误与排查
错误一:用户升级 patch 后编译不过,报参数类型不兼容
先查你是不是悄悄收窄了某个参数或返回类型。用 git diff 看声明产物的变化,而不是看源码——源码里的等价改写可能改变了推断结果。
错误二:TS2416: Property 'x' in type 'A' is not assignable to the same property in base type
用户侧报错但根因在你的接口。常见于你给接口新增了必填成员,而用户有实现类。解法是把它改成可选,或发 major。
错误三:API 报告每次构建都有无关 diff
多半是 @public 标注不稳定或产物顺序依赖文件系统。检查 api-extractor.json 的 mainEntryPointFilePath 是否指向了稳定的中间产物,而不是带 hash 的临时目录。
错误四:prepublishOnly 里跑类型测试很慢
把类型测试拆成独立 job 并在 CI 里并行跑,prepublishOnly 只做「构建 + 快速冒烟」。发布流程慢会诱使人用 --no-verify 绕过,那还不如不设。
错误五:发出去的包少了声明文件
九成是 files 字段没包含声明目录,或构建顺序问题(先 publish 后 build)。发布前一律 npm pack --dry-run 确认清单。
7.3.10 发布自检清单
| 检查项 | 通过标准 |
|---|---|
| 版本号 | 与改动性质匹配,major 对应破坏性变更 |
| 契约快照 | API 报告已提交且 CI 校验 diff |
| 类型测试 | 覆盖公开 API 的关键契约 |
| CHANGELOG | 面向用户描述,含迁移指引 |
| dist-tag | 预发布用 --tag,未污染 latest |
| 弃用策略 | 破坏性删除前有一个 minor 的弃用期 |
files | 声明产物在发布包内 |
| 产物验证 | npm pack --dry-run 清单符合预期 |
| 冒烟测试 | 基于 tarball 安装后能跑通 |
小结
本节把「发布」从一条命令变成了有依据的流程。判定层:破坏性变更分运行时与类型两类,后者的判定依据是「宽进严出」——输入放宽、输出收窄是安全的,反之则可能让用户编译失败;参数收窄、可选变必填、泛型约束收紧都是典型陷阱。门禁层:api-extractor 的 API 报告让每次公开契约改动都有可见 diff,类型测试把「我不打算改这个」写成可执行断言,两者叠加才能在 CI 里拦住类型层回归。流程层:用 changesets 把版本决策挪到改代码的 PR 里,用 dist-tag 做渐进发布,用 @deprecated 留出一个 minor 的迁移窗口,并把能自动化的迁移做成 codemod。
至此第 7 章讲完了库作者的完整链路:产出类型(7.1)、交付双格式(7.2)、按契约发布(7.3)。但类型擦除之后,真正在运行时跑的是 JavaScript——第 8 章会转到运行时视角,从 V8 的类型反馈与 JIT 讲起,看看 TypeScript 精心设计的类型在机器码层面留下了什么。可先延伸阅读 TypeScript SDK 包发布实践 与 语义化版本与依赖解析 。
阅读导航:上一节:7.2 双包(ESM/CJS)与类型解析 · 下一节:8.1 V8 类型反馈与 JIT 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。