本节目标:回答「我的包怎么让别人的编辑器认识它」。读完你会知道
declaration与emitDeclarationOnly各自产出什么、declarationMap为什么值得开、exports里的types条件为什么必须排第一,以及.d.ts/.d.mts/.d.cts三种后缀分别在什么场景下被 Node 与 TypeScript 采纳。
7.1 .d.ts 生成与 exports 映射
前六章我们一直在「消费类型」:写泛型、推条件类型、调 Compiler API。从这一章开始换位——你成了类型的生产者。作为库作者,你交付给别人的其实有两样东西:一份能在 Node 里跑的 JavaScript,和一份能让对方编辑器认出形状的声明文件。两样缺一,包就残了一半。
这一节先解决第一样:声明文件从哪来、放哪去、怎么告诉工具链它在哪。
7.1.1 声明文件的三种来源
声明文件(.d.ts)不是凭空写出来的,工程里它只有三条来路:
| 来源 | 适用场景 | 典型命令 |
|---|---|---|
| tsc 自动生成 | 包本身用 TS 写 | tsc -p tsconfig.build.json |
| 打包器生成 | 用 tsup / rollup 构建 | tsup src/index.ts --dts |
| 手写维护 | 包是纯 JS,或需要覆盖第三方类型 | 直接写 index.d.ts |
绝大多数情况走第一条。第二条本质上是打包器替你调 tsc(或 rollup-plugin-dts),第三条只在「给无类型的老库补声明」时才用。选哪条不重要,重要的是声明与实现必须同源——手抄一份声明是万恶之源,改了实现忘了改声明,消费者就会拿到一个「类型说没问题、运行时报错」的包。
7.1.2 declaration 与 emitDeclarationOnly
最小可用的声明产出配置只需要三行:
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types"
},
"include": ["src/**/*.ts"]
}
declaration: true 让 tsc 为每个 .ts 生成同名 .d.ts。默认它同时也会产出 .js,而当你用 esbuild、swc 或 tsup 负责 JS 产物时,就不需要 tsc 再产一遍 JS——此时加上 emitDeclarationOnly: true,让它只吐声明、不吐实现。
npx tsc -p tsconfig.build.json
ls dist/types
index.d.ts
index.d.ts.map
注意 outDir 与 JS 产物的目录关系。推荐让声明产物单独进 dist/types,JS 进 dist,这样发布后 dist 里不会出现「一堆 .d.ts 和一堆 .js 混在一起」的混乱。若你更喜欢平铺,把 outDir 设成与 JS 相同即可,但务必保证同名文件不互相覆盖。
一个高频坑:declaration: true 要求所有被导出的符号都有可命名的类型。下面这段会报错:
// 错误:导出的变量使用了或正在使用私有名称 'InternalConfig'
interface InternalConfig {
url: string;
}
export const config: InternalConfig = { url: "https://example.com" };
InternalConfig 没被导出,但它是 config 的类型,消费者无法引用它。解决办法是导出它,或在声明里内联展开(tsc 会自动内联匿名对象类型,但不会内联具名接口)。
7.1.3 declarationMap 与产物结构
declarationMap: true 会为每个 .d.ts 生成一个 .d.ts.map,把声明中的位置映射回原始 .ts。它的价值只有一个,但极大:在编辑器里「跳转到定义」时,跳到的是源文件而不是编译产物。
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist/types"
}
}
代价是发布包里多出 sourcesContent,体积略增。对库作者来说这笔买卖划算——用户按住 Ctrl 点进你的函数,看到的是带注释的源码,而不是被压扁的类型。如果你还想让「跳转到实现」也能落到源码,记得同时发布 sourceMap,见 TypeScript 构建性能优化
。
产物的目录结构值得单独看一眼。假设源码是:
src/
index.ts
client.ts
internal/
retry.ts
生成的声明会镜像这个结构:
dist/types/
index.d.ts
index.d.ts.map
client.d.ts
client.d.ts.map
internal/
retry.d.ts
retry.d.ts.map
这意味着 internal/ 下的声明也被发布了,即使 src/index.ts 从没导出过它。消费者可以写 import { retry } from "mylib/dist/types/internal/retry" 绕过你的公开 API。要堵住这条路,需要靠 exports 白名单——见 7.1.5。
7.1.4 把散落声明压成单文件
上面那种「一个源文件对应一个 .d.ts」的形态有个副作用:包的内部结构被暴露了。对只导出几个符号的小包,我们更希望产出一个 index.d.ts 搞定一切。这时用 API Extractor:
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "<projectFolder>/dist/types/index.d.ts",
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/dist/index.d.ts"
},
"compiler": {
"tsconfigFilePath": "<projectFolder>/tsconfig.build.json"
}
}
npx api-extractor run --local
它读取 dist/types/**/*.d.ts,压成单个 dist/index.d.ts,具体做三件事:把 import 递归内联、剔除未被公开 API 触及的声明、把注释里的 @public / @internal 标签作为裁剪依据。这正好解决了上一节「internal 声明被发布」的问题。
| 方案 | 产物 | 适用 |
|---|---|---|
| tsc 直出 | 多文件镜像结构 | 大型库、需要保留模块边界 |
| api-extractor | 单文件 | 中小型库、希望隐藏内部结构 |
| rollup-plugin-dts | 单文件 | 已在用 rollup,想少一个工具 |
7.1.5 types 字段与 exports 的 types 条件
声明产出后,必须告诉解析器它在哪。老办法是在 package.json 写顶层 types:
{
"name": "mylib",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts"
}
types 是 TypeScript 早期(node10 / node 解析模式)唯一的类型入口。它的局限是只能有一个:整个包只认一个声明文件,无法表达「ESM 入口用这份、CJS 入口用那份」。
现代做法是在 exports 里用 types 条件:
{
"name": "mylib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
}
}
这份配置的读法是:exports 是一个路径映射表,键 . 表示包的根入口,值是一个条件对象。当有人 import "mylib" 时,解析器自上而下扫描条件对象,取第一个命中的分支。
请留意 "./package.json": "./package.json" 这一行。一旦启用 exports,它就成了白名单:没列进去的子路径一律不可访问。很多工具(Vite、webpack、各种 CLI)会去读你的 package.json,所以这行几乎是标配。
7.1.6 条件顺序:types 必须排第一
这是本章最容易踩、后果最隐蔽的坑。看这份错误配置:
{
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}
它「看起来」很合理:先给运行时用的 JS,再给类型。但顺序错了。条件匹配是首个命中即停,import 排在 types 前面,于是当 TypeScript 以 ESM 方式解析时,它先命中了 import 分支,拿到 ./dist/index.js——一个 .js 文件。TypeScript 会尝试在同目录找 index.d.ts 兜底,找不到就报:
error TS7016: Could not find a declaration file for module 'mylib'.
'.../node_modules/mylib/dist/index.js' implicitly has an 'any' type.
正确写法只有一个:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
规则:types 条件必须是每个条件对象的第一项,且尽量与它描述的运行时分支持有相同的前缀条件(比如 ESM 用 ./dist/index.d.ts,CJS 用 ./dist/index.d.cts)。这条规则连 API 文档都反复强调,原因是它不靠「特殊照顾」实现,而是靠顺序。
| 条件 | 谁在匹配 | 典型值 |
|---|---|---|
types | TypeScript | ./dist/index.d.ts |
import | ESM 运行时 / 打包器 | ./dist/index.js |
require | CJS 运行时 | ./dist/index.cjs |
default | 兜底 | ./dist/index.js |
default 永远放最后。它不做任何判断,谁走到它谁就命中,所以放在前面会让后面所有条件变成死代码。
7.1.7 三种声明后缀
随着双包普及,.d.ts 一个后缀不够用了:
| 后缀 | 对应 JS | 解析时的模块语义 |
|---|---|---|
.d.ts | .js | 取决于最近的 package.json 的 type |
.d.mts | .mjs | 强制 ESM |
.d.cts | .cjs | 强制 CJS |
区别在于模块格式的判定是否依赖外部文件。.d.ts 自身不带格式信息,得向上找 package.json 的 type 字段;.d.mts / .d.cts 则把格式写进了扩展名,任何目录下语义都一致。在 NodeNext 模式下,声明文件的扩展名必须与它描述的 JS 一致,否则会报:
error TS1479: The current file is a CommonJS module whose imports will produce
'require' calls; however, the referenced file is an ECMAScript module...
所以在双包场景里,你会看到 dist/index.d.ts(ESM 入口)与 dist/index.d.cts(CJS 入口)并存。这块细节与 moduleResolution 各模式强相关,7.2 节会连同双包构建一起展开,模块解析模式的完整对照可先延伸阅读 TypeScript 模块解析:ESM 与 CJS
。
7.1.8 手写声明与全局增强
有些场合必须手写:包是纯 JS 写的、或者要给第三方模块补类型。此时用 declare:
// types/env.d.ts:为已有的全局变量补声明
declare const __BUILD_VERSION__: string;
declare function gtag(command: string, ...args: unknown[]): void;
interface Window {
__APP_STATE__?: Record<string, unknown>;
}
文件里只要出现顶层 import 或 export,它就会从「全局脚本」变成「模块」,上面的 declare 就不再挂到全局,而是变成模块内局部声明。这是最高频的手写声明错误。要在模块里做全局增强,必须用 declare global:
// types/augment.d.ts
export {};
declare global {
interface Window {
__APP_STATE__?: Record<string, unknown>;
}
}
那个 export {} 不是装饰,它把文件标记成模块,declare global 才会被编译器接受。为无类型库写声明时,最省事的形态是模块声明:
declare module "legacy-analytics" {
export function track(event: string, props?: Record<string, unknown>): void;
export const version: string;
}
这比逐个文件写 .d.ts 快得多,代价是失去精确性。库作者偶尔也需要它:当你的包必须依赖一个没有类型的传递依赖时,用它兜底比在业务代码里到处 any 干净。更系统的声明文件组织方式可延伸阅读 TypeScript 工程化进阶
。
7.1.9 常见错误与排查
错误一:Could not find a declaration file for module 'x'
九成是 exports 里漏了 types 条件,或条件顺序把 types 排到了后面。先检查顺序,再检查路径是否真实存在——exports 里的路径必须以 ./ 开头。
错误二:Cannot find module 'x' or its corresponding type declarations
如果包本身没问题,看消费者的 moduleResolution。node10 模式不读 exports,只看顶层 types / main。所以面向老项目发布时,建议 types 与 exports 两套都写,前者兼容旧解析器。
错误三:TS4023: Exported variable 'x' has or is using name 'Y' from external module but cannot be named
你在公开 API 里用了未导出的类型。要么导出它,要么在声明点显式标注一个可命名的类型。
错误四:发布后本地正常、装到别的项目报错
多半是 files 字段没包含 dist/types。检查:
{
"files": ["dist"]
}
然后 npm pack --dry-run 看一眼实际入包清单,比读 package.json 可靠得多。
错误五:.d.ts 里出现 import("./src/foo") 这种相对源码路径
说明 rootDir 推断错了,tsc 把声明写成了指向源码。显式设置 "rootDir": "src" 通常能解决。发布前用 grep -r '\.\./src' dist/types 自查一遍。
7.1.10 类型入口自检清单
| 检查项 | 通过标准 |
|---|---|
declaration | true,且与 JS 产物同源生成 |
emitDeclarationOnly | 用外部打包器产 JS 时为 true |
declarationMap | true,保证跳转落到源码 |
exports.types | 存在于每个条件对象,且为第一项 |
exports 白名单 | 已显式导出 ./package.json |
顶层 types | 保留,兼容 node10 解析器 |
files | 包含声明产物目录 |
| 产物自查 | npm pack --dry-run 无意外文件 |
小结
本节把「包如何交付类型」拆成两半。产出侧:declaration 让 tsc 生成 .d.ts,emitDeclarationOnly 在外部打包器产 JS 时避免重复劳动,declarationMap 让消费者跳转到源码;产物若是多文件镜像结构,可用 api-extractor 或 rollup-plugin-dts 压成单文件,顺便隐藏内部模块。交付侧:exports 取代了单一的 types 字段,让 ESM 与 CJS 各有各的类型入口,但条件是自上而下首个命中即停,因此 types 必须排在每个条件对象的第一位——顺序错了,消费者只会拿到一个隐式 any。最后,.d.ts / .d.mts / .d.cts 的差别在于模块格式是「外部推断」还是「扩展名写死」,NodeNext 下二者必须一致。
类型入口就位后,还有一半问题没解决:同一个包如何同时产出 ESM 与 CJS 两套产物,并让两套类型各自对得上?这就是下一节 7.2 双包(ESM/CJS)与类型解析 的主题。若你更关心类型入口在真实 SDK 项目里的落地形态,可延伸阅读 TypeScript SDK 包发布实践 与 TypeScript API 类型生成 。
阅读导航:上一节:6.3 重构工具与 codemod · 下一节:7.2 双包(ESM/CJS)与类型解析 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。