本节目标:搞清楚「一个 npm 包凭什么能被 TypeScript 正确理解」。读完你能读懂任何包里的
package.json入口字段、知道exports里types条件的顺序为什么不能随便写,并能给自己的包配出一份同时支持 ESM 与 CJS 的正确配置。
11.3 npm 包、类型声明与 exports
前两节我们站在使用者的位置:导入别人的包、解决解析失败。这一节换到作者的位置:假设你要发布一个包,怎么让 ESM 用户、CJS 用户、TypeScript 用户三方都满意?
这个问题的答案几乎全部集中在 package.json 的入口字段上。而这块恰恰是 npm 生态里历史包袱最重的地方——你会同时看到 main、module、types、exports 四个字段,它们出现于不同年代,语义互相重叠又各有缺口。
11.3.1 一个包的最小结构
先把要讨论的对象具象化。一个可发布的包至少长这样:
my-lib/
package.json
src/
index.ts
math.ts
dist/
index.js
index.d.ts
README.md
src/ 是源码,dist/ 是构建产物,而使用者只会接触 dist/ 和 package.json。理解这一点很关键:包的类型体验好不好,取决于 dist/index.d.ts 与 package.json 里那几行字段,而不取决于你源码写得多漂亮。
11.3.2 入口字段的历史层积
四个字段按出现时间排列,正好是一部 Node 模块史:
| 字段 | 出现年代 | 谁在用 | 解决的问题 |
|---|---|---|---|
main | Node 早期 | Node 的 require | CJS 入口 |
module | 2017 前后 | 打包器(webpack、Rollup) | ESM 入口,但不是 Node 标准 |
types | TypeScript 2.0 | tsc | 类型声明入口 |
exports | Node 12.7+ | Node、打包器、tsc | 统管所有入口,可做条件导出 |
{
"name": "my-lib",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
这套「三字段并排」的配置曾经是标准答案,今天仍然能用,但有几个已知缺口:
module不是 Node 标准。Node 完全忽略它,只有打包器认。所以它带来的「ESM 支持」是打包器给的,不是运行时给的。- 无法表达条件。同一个包在
import和require下需要不同产物,旧字段表达不了。 - 子路径无法控制。
import "my-lib/internal/secret"会直接穿透到文件系统,把内部实现暴露出去。 types只有一份,无法与条件导出联动。
exports 就是为了解决这四个缺口而生的。
11.3.3 exports 字段:条件导出
exports 用一棵嵌套的对象描述「哪些路径、在什么条件下,解析到哪个文件」:
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./math": {
"types": "./dist/math.d.ts",
"import": "./dist/math.js",
"require": "./dist/math.cjs"
}
}
}
逐层读这个结构:
"."代表包根入口,即import "my-lib"。"./math"是子路径,对应import "my-lib/math"。- 每个路径下面是一组条件:
types、import、require。解析时按顺序从上往下匹配,第一个命中的生效。
这里有两个必须记住的规则。
规则一:条件顺序有意义,types 必须放最前。
这不是风格问题。解析器是顺序匹配的,如果把 import 写在 types 前面,那么当 TypeScript 来查找类型时,它会先命中 import 条件、拿到一个 .js 文件,然后去猜同名的 .d.ts——猜错了就报:
error TS7016: Could not find a declaration file for module 'my-lib'.
所以 types 永远排第一。
规则二:一旦使用 exports,未列出的子路径就被封死。
import x from "my-lib/dist/internal"; // 报错:Package subpath is not defined by "exports"
这是特性不是缺陷——它让包的内部结构真正成为私有实现。需要开放某些路径时显式加上即可:
{
"exports": {
".": "./dist/index.js",
"./math": "./dist/math.js",
"./package.json": "./package.json"
}
}
把 "./package.json" 显式导出一项是常见做法,因为不少工具(构建系统、版本检测脚本)需要读它。
11.3.4 条件的完整清单
除了 types、import、require,还有几个常用条件:
| 条件 | 含义 | 备注 |
|---|---|---|
types | 类型声明文件 | 必须放在首位 |
import | 被 import 或 import() 加载时 | 走 ESM |
require | 被 require() 加载时 | 走 CJS |
default | 兜底,必须放最后 | 所有条件都不匹配时使用 |
node | 仅 Node 环境 | 与 browser 互斥 |
browser | 仅浏览器打包环境 | 打包器识别 |
development / production | 按 NODE_ENV 区分 | 较少使用 |
default 的位置规则与 types 相反:它必须放最后,因为一旦命中就不会再往下看。写成「default 在前」等于后面的条件全是死代码。
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"browser": "./dist/index.browser.js",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
}
}
顺带说明一个容易混淆的点:exports 里的条件只决定解析到哪个文件,不改变该文件的模块类型。文件是 ESM 还是 CJS,仍由上一节讲的扩展名与 type 字段决定。
11.3.5 类型声明的三种来源
现在把镜头对准类型。当你在代码里 import { z } from "zod" 时,z 的类型从哪来?只有三种可能:
| 来源 | 判定方式 | 典型例子 |
|---|---|---|
| 包自带声明 | 包内有 .d.ts,且 types/exports.types 指向它 | zod、axios |
| DefinitelyTyped | 安装了 @types/包名 | @types/node、@types/express |
| 自己声明 | 项目内写 .d.ts 或用 declare module | 老旧的内部库 |
查找顺序是:先看包内有没有声明,再看有没有 @types,都没有就报 TS7016。第 12 章会完整展开第二、三种来源,这里只讲第一种——因为它是包作者的责任。
包作者要做的就是把声明文件放进产物并指对路径。最小配置是 declaration: true:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
declarationMap 值得顺手打开:它为每个 .d.ts 生成一份 source map,使用者按住 Ctrl 点击类型时能跳到你的源码而不是编译产物。对库作者来说,这是很划算的一行配置。
11.3.6 typesVersions:给老版本 TypeScript 兜底
有些老版本 tsc 不认识 exports 字段(TypeScript 4.7 之前完全不支持),只会读顶层 types。如果你的包要照顾这些用户,可以用 typesVersions 做映射:
{
"types": "./dist/index.d.ts",
"typesVersions": {
"<4.7": {
"*": ["./dist-legacy/*"]
}
}
}
它的键是 semver 范围,值是一个「子路径 → 文件数组」的映射表。新项目基本不需要它——只要最低支持的 TypeScript 版本不低于 4.7,exports 就足够了。这里列出来是为了让你在别人的包里见到它时不至于困惑。
11.3.7 发布自己的包:完整配置
把前面的规则组装起来,给一个双格式库的完整 package.json:
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"prepublishOnly": "npm run build"
},
"sideEffects": false,
"peerDependencies": {
"typescript": ">=4.7"
}
}
几个字段值得解释:
files是白名单:只有列出的内容会被打进 tarball。不写的话,src/、测试文件、.env都可能被一起发布出去。这是最常见的发布事故。prepublishOnly在npm publish之前自动跑构建。没有它,你可能发布一个没有dist/的空包。sideEffects: false告诉打包器「本包的模块没有副作用,可以放心摇树」。写错这一项会导致使用者的产物里丢掉必要的副作用导入——比如 polyfill 或样式注册。type: "module"让.js产物按 ESM 解析,因此 CJS 产物必须用.cjs扩展名。这正是上一节那条规则的落地。
发布前建议做一次干跑,确认 tarball 内容符合预期:
npm pack --dry-run
它会打印将要发布的文件清单与体积,是发现「不小心带上 node_modules 或源码」最快的手段。
11.3.8 双格式的代价
上一节提到的双包危害在包作者这边有个直接结论:双格式发布是有成本的。
如果 .js(ESM)和 .cjs 是两份独立构建,那么它们各自持有独立的模块级状态。使用者如果一部分代码走 ESM 入口、另一部分(比如某个老依赖)走 CJS 入口,就会拿到两个不同的实例。
权衡如下:
| 策略 | 优点 | 代价 |
|---|---|---|
| 只发 CJS | 兼容性最好,老工具全支持 | 无法被 tree-shaking,ESM 用户体验差 |
| 只发 ESM | 结构最简单,无双包危害 | CJS 使用者只能用动态 import() |
| 双格式 | 两边都照顾 | 双包危害、构建复杂度翻倍 |
新项目的主流选择是只发 ESM——Node 已支持 require 加载同步 ESM,CJS 的存量压力正在快速下降。但如果你的包会被大量老项目依赖,双格式仍是稳妥选择。做决定前先想清楚:包里有没有模块级可变状态? 没有的话,双包危害基本无感;有的话,务必谨慎。
11.3.9 使用者视角的三类报错
站在包的消费者一侧,最常见的三类报错都能回溯到本节内容:
error TS7016: Could not find a declaration file for module 'x'.
包没带声明,也没有对应的 @types。解法依次是:装 @types/x、联系包作者、自己写声明(第 12 章)。
Package subpath './dist/xxx' is not defined by "exports" in .../package.json
包的 exports 封死了这个子路径。不要试图绕过——正确的做法是改用包公开的 API。这类报错往往是升级包版本后出现的,说明作者收紧了公开面。
Module '"x"' has no exported member 'y'.
类型声明与运行时不一致,或你用了未导出的内部类型。先确认版本是否匹配(升级过包但锁文件没更新也会这样),再看该成员是否真的在 exports 的公开面里。
11.3.10 工程实践小结
把本节要点压缩成一份作者清单,发布前逐条核对:
| 检查项 | 为什么 |
|---|---|
exports.types 放在条件对象首位 | 否则类型解析会命中 .js |
default 条件放在最后 | 否则后续条件是死代码 |
files 显式白名单 | 避免把源码、测试、密钥发出去 |
declaration + declarationMap 都开 | 使用者能跳转到源码 |
prepublishOnly 挂构建脚本 | 避免发布空包 |
npm pack --dry-run 验证 | 肉眼确认 tarball 内容 |
想进一步了解包发布的完整流程(版本策略、变更日志、CI 自动发布),可以延伸阅读 TypeScript SDK 包发布实践 ;多包仓库的组织方式见 TypeScript Monorepo 与 Turborepo ;工程配置的整体组织见 TypeScript 项目架构与 tsconfig 。
小结
本节从包作者视角回答了一个问题:一个 npm 包凭什么能被 TypeScript 正确理解。答案是 package.json 的入口字段——main、module、types 是历史层积的产物,各有缺口;exports 用一棵条件树统一了它们,并顺带解决了「内部路径暴露」的问题。
我们重点确认了两条顺序规则:types 必须放最前(否则类型解析命中 .js 后报 TS7016),default 必须放最后(否则后续条件是死代码)。类型声明有三种来源,包自带的声明由 declaration: true 产出,declarationMap 能让使用者跳回源码。发布环节的关键是 files 白名单与 prepublishOnly 构建钩子,用 npm pack --dry-run 验证。最后我们把双包危害落成作者侧的决策依据:包内有没有模块级可变状态。
模块与包这条线到此告一段落。但本节反复出现的 .d.ts 一直是黑盒——它是怎么写的、@types 包为什么能被自动发现、如何给一个完全没有类型的库补上声明,这些是 12.1 .d.ts 与 @types 机制
的主题,也是你从「会用 TypeScript」走向「能治理一个 TypeScript 项目」的必经一步。
阅读导航:上一节:11.2 ESM/CJS 互操作与 moduleResolution · 下一节:12.1 .d.ts 与 @types 机制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。