《TypeScript高级编程》7.3 semver、发布与类型破坏性变更

对库作者而言,类型层的破坏性变更比运行时更难察觉——代码照跑,用户却编译不过。本节先给出一份类型破坏性变更清单,再用 api-extractor 的 API 报告与类型测试把公开契约锁进 CI,最后梳理 changesets 驱动的发布流程、dist-tag 与弃用周期,让每个版本号都名副其实。

本节目标:让版本号说实话。读完你会知道哪些类型改动属于破坏性变更(很多是运行时看不出来的)、怎么用 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes