TypeScript 库作者指南:声明文件、API 演进与包发布

系统覆盖 TypeScript 库(npm 包)开发与发布:tsconfig 构建配置、声明文件生成、API 设计(泛型/重载/模块暴露)、包导出入口(exports/main/types)、兼容性与 semver 演进、sideEffects 与 tree-shaking、发布流程、声明文件测试,以及开源库的工程实践。

引言

「会写 TS 应用」和「会写 TS 库」是两件事。应用只需要「代码能跑」,库还要求「类型能给别人用」:别人 import 你的包时,npm install 后 .d.ts 要能正确解析、泛型要够灵活、API 演进不能破坏旧用户、被 tree-shaking 后不能留垃圾。这就是「库作者」视角。

本文系统讲 TS 库开发全流程:先讲 tsconfig 构建与声明文件生成,再深入 API 设计(泛型/重载/模块暴露),覆盖包导出入口(package.json 的 exports/main/types)、兼容性与 semver 演进、tree-shaking 与 sideEffects、发布流程(prepublishOnly/build/test)、声明文件测试,最后给工程实践清单。

前置:/typescript/(TS 基础)、/typescript-project-architecture-tsconfig/(模块与项目架构)、/typescript-build-performance-optimization/(构建)、/typescript-api-type-generation/(契约)。


目录


1. 库 vs 应用:视角差异

1.1 关键差异

维度应用代码库代码
类型消费者自己别人(import 你的包)
构建目标可运行可被引用(ESM+CJS+types)
API 责任内部约定公开契约,不可随意改
包体积无所谓影响用户 bundle
兼容单一环境多构建/多 TS 版本
发布部署发布到 npm + 版本管理

1.2 库的「三件套」

一个合格 npm 包至少提供:
  1. ESM 构建(现代,支持 tree-shaking)
  2. CJS 构建(旧 Node/遗留工具兼容)
  3. .d.ts 声明(类型可用)
package.json 用 exports 精确指向三套产物

1.3 心智模型

应用代码 = 「用别人的类型」
库代码   = 「提供别人能用的类型」
库的每一次导出,都是一份「不能再违约的契约」

一句话总结:应用只要自己跑得通,库要「别人能正确引用」——产物(ESM/CJS/types)与公开 API 都是承诺。


2. tsconfig 构建配置

2.1 库的 tsconfig

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

2.2 三个关键开关

declaration: true   → 生成 .d.ts(类型对用户可见)
declarationMap: true → 类型可跳转到源码(开发体验)
isolatedModules     → 与 esbuild/tsc 转译兼容(防 import 误用)

2.3 ESM/CJS 双构建

# 方案一:tsc 双输出
tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json

# 方案二:tsc 出 ESM + tsup/esbuild 转 CJS
tsup src/index.ts --format esm,cjs --dts

一句话总结:库构建三开关(declaration/declarationMap/isolatedModules)+ 双产物(ESM+CJS)——用 tsc 出类型与 ESM、tsup/esbuild 补 CJS。


3. 声明文件:d.ts 生成与测试

3.1 自动生成 vs 手写

99% 场景:declaration: true 自动生成(从源码推导)
手写 .d.ts 场景:
  - 包裹无类型的 JS 依赖
  - 对外类型比实现更「简练/稳定」
  - 环境声明(全局变量、CSS modules)

3.2 生成 vs 手写的对照

// 源码 src/index.ts
export function join(a: string, b: string): string {
  return a + b
}
// 自动生成 dist/index.d.ts
export declare function join(a: string, b: string): string

3.3 声明文件测试(dtslint / attw)

1. attw(Are the Types Wrong):
   检查「发布包的类型解析」在 Node16/bundler 等解析模式是否正确
2. 类型测试(tsd/expect-type):
   在测试里断言类型(expectTypeOf(x).toEqualTypeOf<Foo>())
3. 冒烟:在示例工程里 import 你的 dist,跑 typecheck
// test/types.test-d.ts(tsd)
import { expectType } from 'tsd'
import { join } from '../dist'

expectType<string>(join('a', 'b'))
// 参数类型错误则本文件编译失败 → 测试失败

一句话总结:声明文件靠 declaration 自动生成,手写只留给「包裹无类型依赖 / 对外契约简练」;用 tsd/expect-type 断言类型、attw 检查解析,保证发布包的 d.ts 真能被人用。


4. API 设计:泛型与重载

4.1 让泛型「推导」而非「标注」

// 好:泛型从参数推导,调用者无需标注
export function identity<T>(x: T): T { return x }
const a = identity('hi')      // a: 'hi'(字面量推导)

// 更精确:const 断言保留字面量
export function tuple<const T extends readonly unknown[]>(...xs: T): T {
  return xs
}
const t = tuple('a', 1, true)  // t: readonly ['a', 1, true]

4.2 重载:让 API 收窄

// 无重载:调用者要自己收窄
export function pick(o: object, k: string): unknown

// 有重载:按参数组合给精确返回
export function pick<T, K extends keyof T>(o: T, k: K): T[K]
export function pick<T, K extends keyof T>(o: T, ks: readonly K[]): Pick<T, K>
export function pick<T, K extends keyof T>(
  o: T, k: K | readonly K[],
): T[K] | Pick<T, K> {
  return Array.isArray(k)
    ? Object.fromEntries(k.map(x => [x, o[x]]))
    : o[k]
}

4.3 模块暴露的纪律

1. 从 index 导出「公共面」,内部工具放 src/internal(不导出)
2. 用「导出类型而非实现」隐藏可变内部状态
3. 慎用 any:会污染用户类型
4. 方法 vs 属性:暴露「函数集合」而非可被改写的方法

一句话总结:库 API 设计 = 泛型让调用者零标注 + 重载收窄返回 + 只暴露公共面——「推导优先、收窄精确、隐藏实现」。


5. 包导出入口:exports/main/types

5.1 package.json 三件套

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./package.json": "./package.json"
  }
}

5.2 exports 的正确用法

exports 字段作用:
  1. 精确控制「哪些子路径可被 import」(子模块白名单)
  2. 按条件(import/require/types)分发产物
  3. 未列出的子路径 → 禁止访问(防止内部泄漏)

顺序重要:types 放最前(TS 先解析类型)
错误写法:漏 types → TS 用户报「找不到声明」

5.3 子路径导出

{
  "exports": {
    ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
    "./utils": { "types": "./dist/utils.d.ts", "import": "./dist/utils.js" }
  }
}

一句话总结:exports 是包入口的「白名单 + 条件分发」——types/import/require 三条件、types 在前;只暴露想公开的子路径。


6. 兼容性与 semver 演进

6.1 semver 规则

major:破坏性变更(删 API、改签名、换类型含义)
minor:新增功能(向后兼容:加函数、加字段、加泛型约束放宽)
patch:修复 Bug(不改变任何可见行为/类型)

库纪律:
  1. 破坏性类型变更 = major(即使用户运行时不报错)
  2. 类型「放宽」可以 minor,类型「收紧」算破坏
  3. 任何签名变化前先想「旧调用是否还能编译」

6.2 API 演进的操作

废弃:保留旧 API + @deprecated 标记(IDE 划线提示迁移)
替换:新名字 + 旧名字别名(过渡一个 minor)
删除:major 版本里删(废弃至少提前一个 major 通知)
类型演进优先「加法」:加可选字段/联合成员/新泛型参数默认值

6.3 检测破坏性变更

# api-extractor + api-diff:对每个版本生成 API 报告,diff 发现破坏
npx api-extractor run --local   # 生成 .api.md 报告
# CI 中:旧报告 vs 新报告 diff → 破坏性变更即失败

一句话总结:semver 纪律 = 破坏性变更进 major、废弃要打 @deprecated 并过渡、用 api-extractor 的 API 报告 diff 兜底检测破坏——类型也是一种承诺。


7. tree-shaking 与 sideEffects

7.1 为什么重要

应用打包时,只应「保留实际用到的代码」
库要做三件事让 tree-shaking 生效:
  1. 提供 ESM(tree-shaking 只对 ESM 有效)
  2. 声明 sideEffects: false
  3. 副作用代码放到模块顶层之外(或在内部隔离)

7.2 sideEffects 声明

{
  "sideEffects": false,
  "exports": {
    "./styles.css": "./dist/styles.css"
  }
}
sideEffects: false → 打包器可安全删除「未被 import 的导出」
若库有副作用(注入全局、注册 polyfill):
  - 放单独子路径(如 ./polyfill)
  - sideEffects 列白名单:["**/polyfill.js"]

7.3 写法的副作用纪律

// 反例:模块顶层有副作用(初始化),会被误判可删或删不掉
const cache = initGlobalCache()      // ❌ 顶层副作用

// 正例:惰性初始化,副作用延迟到使用时
let cache: Cache | undefined
function getCache() { return cache ??= initGlobalCache() }   // ✅

一句话总结:让 tree-shaking 生效 = 提供 ESM + sideEffects: false + 顶层无副作用;副作用代码走独立子路径或惰性初始化。


8. 发布流程与版本管理

8.1 发布前检查清单

# 完整流程
npm run build          # 1. 构建(ESM + CJS + types)
npm test               # 2. 跑测试
npm run typecheck      # 3. 类型检查(含测试)
npm run lint           # 4. lint
npx attw --pack .      # 5. 检查发布包类型解析
node -e "require('.')" # 6. CJS 冒烟
node --input-type=module -e "import('.').then(...)"  # 7. ESM 冒烟
npm publish            # 8. 发布

8.2 用 prepublishOnly 自动化

{
  "scripts": {
    "prepublishOnly": "npm run build && npm test && npx attw --pack .",
    "publish:patch": "npm version patch && npm publish",
    "publish:minor": "npm version minor && npm publish",
    "publish:major": "npm version major && npm publish"
  }
}

8.3 版本管理工具

changesets / semantic-release:
  1. PR 里写变更集(minor/major/patch)
  2. 发布时自动更新版本号 + 生成 changelog
  3. 保证「版本号 = 语义变更」的一致性

一句话总结:发布流程 = prepublishOnly 串起 build/test/attw/冒烟,用 changesets/semantic-release 让版本号自动对齐语义变更——发布前每一项检查都在门禁里。


9. 开源库的工程实践

9.1 质量门禁组合

单元测试    :vitest/jest(行为正确)
类型测试    :tsd/expect-type(类型正确)
类型解析检查:attw(发布包 d.ts 可解析)
API 报告   :api-extractor(检测破坏性变更)
示例冒烟   :examples/ 目录 typecheck(真实用法验证)
覆盖率     :核心逻辑 ≥ 80%

9.2 文档与示例

1. README 带「快速开始」代码块(同步到 examples/)
2. JSDoc 注释会进 .d.ts → 用户 IDE 悬浮提示(写好它)
3. 类型 vs 行为分开测:类型测试不替行为测试
4. 维护「破坏性变更 / 迁移指南」章节

9.3 常见坑

1. 忘发 types 或 exports 顺序错 → 用户类型报错
2. 库内用 any → 污染用户类型推断
3. 顶层副作用 → 用户 tree-shaking 失效
4. 依赖放 dependencies 还是 peerDependencies 不清晰
5. 不锁 Node/TS 兼容范围说明

一句话总结:开源库工程实践 = 行为测试 + 类型测试 + attw + API 报告四层门禁,JSDoc 写好以养用户 IDE 体验——库质量从「自己测」升维到「让别人用好」。


10. 速查表

需求方案
生成声明declaration: true
双构建tsc ESM + tsup/esbuild CJS
包入口exports(types/import/require)
类型解析检查attw
类型测试tsd / expect-type
破坏性检测api-extractor diff
版本语义semver(破坏→major)
tree-shakingESM + sideEffects: false
副作用隔离独立子路径 / 惰性初始化
发布自动化prepublishOnly + changesets

一句话记忆:TS 库作者的核心是「让别人能用好你的类型」——tsconfig 开 declaration 生成 d.ts、exports 三条件(types/import/require)做入口白名单、API 设计泛型推导 + 重载收窄、只暴露公共面;semver 纪律「破坏性变更进 major、废弃打 @deprecated 过渡」、api-extractor 报告 diff 兜底;tree-shaking 靠 ESM + sideEffects:false + 顶层无副作用;发布用 prepublishOnly 串起 build/test/attw/冒烟、changesets 管版本——把你的包当「对外契约」来维护,用户才敢长期依赖。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. TypeScript 错误处理:Result 模式、类型化错误与错误边界实战
  2. TypeScript 测试策略:单元测试、类型测试与测试替身实战
  3. TypeScript 构建性能优化:增量编译、缓存与工具链选型