《TypeScript编程入门》11.3 npm 包、类型声明与 exports

本节从使用者视角切换到包作者视角,讲清一个 npm 包如何被 TypeScript 正确识别。先看 package.json 里 main、module、types 这些历史字段的来历与局限,再重点拆解 exports 条件导出与其中的 types 条件、顺序要求,最后给出类型声明的三种来源、发布自己包的完整配置,以及消费者最常撞上的几类报错。

本节目标:搞清楚「一个 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 模块史:

字段出现年代谁在用解决的问题
mainNode 早期Node 的 requireCJS 入口
module2017 前后打包器(webpack、Rollup)ESM 入口,但不是 Node 标准
typesTypeScript 2.0tsc类型声明入口
exportsNode 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 机制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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