LIVING WEB MAGAZINE
komugi.小麦

小麦的照片,还有一些生活与工程手记

目录 (6 节)↓
TypeScriptISSUE 2026-10

声明文件明明存在,为什么安装 npm 包后还是 TS7016?

npm 包里明明有声明文件,为什么导入时仍报 TS7016?从一个最小复现出发,解释 exports、types 与扩展名替换如何影响类型解析,并给出修复方法和发布前检查清单。

·2 min read

安装一个 npm 包,JavaScript 入口能够加载,TypeScript 却提示 TS7016:找不到这个模块的声明文件。打开安装目录,index.d.ts 就在那里,package.json 顶层也有 types。

问题可能出在声明文件与导入入口之间:文件已经打进包,却没有接到编译器实际采用的解析路径上。沿着这条路径检查,才能解释为什么 JavaScript 能运行,类型检查却失败。

下面用虚构包 @example/text-kit 复现这一问题。实验使用 TypeScript 5.9.3,分别检查 NodeNext 与 Bundler 解析;包名、实现和目录均为独立构造的教学示例。

一个文件齐全,却找不到类型的包

先创建独立实验目录并安装编译器,再手工放入测试包:

mkdir ts7016-lab
cd ts7016-lab
npm init -y
npm install --save-dev --save-exact typescript@5.9.3
mkdir -p node_modules/@example/text-kit/dist

这里手工创建 node_modules 内容,只为观察入口解析。实验期间不要重新安装依赖,以免测试包被清理;真实发布验收应使用打包后的 .tgz,后文会说明。

在 node_modules/@example/text-kit/package.json 写入:

{
  "name": "@example/text-kit",
  "version": "1.0.0",
  "type": "module",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "default": "./dist/runtime.js"
    }
  }
}

运行时文件 node_modules/@example/text-kit/dist/runtime.js:

export function shout(text) {
  return text.toUpperCase();
}

声明文件 node_modules/@example/text-kit/dist/index.d.ts:

export declare function shout(text: string): string;

在实验目录创建 consumer.mts。.mts 明确表示 ESM,避免结果受父目录 package.json 的模块类型影响:

import { shout } from "@example/text-kit";

console.log(shout("hello"));

再创建 tsconfig.nodenext.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "types": []
  },
  "files": ["consumer.mts"]
}

运行类型检查和运行时检查:

./node_modules/.bin/tsc -p tsconfig.nodenext.json
node --input-type=module -e 'import { shout } from "@example/text-kit"; console.log(shout("hello"))'

第一条命令报 TS7016,第二条输出 HELLO。两者并不矛盾:Node 找到了可执行的 JavaScript,TypeScript 没有找到与这条导入路径对应的声明。

strict 包含 noImplicitAny。关闭它可能让这条诊断消失,但不会凭空生成类型信息,后续调用仍可能按 any 处理。

编译器为什么没有采用顶层 types

moduleResolution 决定编译器怎样从模块名查找目标。NodeNext、Node16 和 Bundler 等模式默认会读取包的 exports;module 则参与决定模块语义,以及解析时使用哪类导入条件。即使设置了 noEmit,这些规则仍然参与类型检查。TypeScript 模块解析参考

这个实验的查找过程可以简化为:

import "@example/text-kit"
  → 读取 package.json 的 exports["."]
  → 匹配 default,得到 ./dist/runtime.js
  → 尝试 runtime.ts、runtime.tsx、runtime.d.ts
  → 没有找到对应类型文件,最终解析到 runtime.js
  → 在严格检查下报告 TS7016

从 .js 路径尝试同名 TypeScript 文件或声明文件的过程,叫作扩展名替换。编译器会保留 runtime 这个文件名,不会扫描目录,再猜测旁边的 index.d.ts 是否描述了它。扩展名替换规则

顶层 types 适用于未通过 exports 解析包入口的情况。当前请求已经按 exports 查找时,它不会作为任意失败后的备用入口;typesVersions 在读取 exports 的场景下也不会接管这次解析。包入口字段与 typesVersions

因此,这个问题由几项条件共同造成:消费者遵循 exports,根入口指向 runtime.js,而声明文件叫 index.d.ts,两者之间又没有显式映射。单独看到包里存在 .d.ts,不足以判断导入能否获得类型。

最小修复是把声明接到实际入口上

保持文件不变,把测试包的 exports 改成:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/runtime.js"
    }
  }
}

这是替换原 exports 字段的片段,其余字段保持不变。TypeScript 能识别 types 条件,直接找到 index.d.ts;Node 默认不启用该条件,仍通过 default 加载 runtime.js。

条件对象的键顺序会影响匹配优先级,types 应放在对应条件对象的前面,default 放在后面。不要依赖 JSON 格式化工具随意重排这类字段。Node 条件导出规则

再次执行类型检查,TS7016 应消失。还可以暂时把消费者中的调用改成 shout(123):如果出现参数类型不匹配的 TS2345,就说明编译器确实读到了声明中的 string 约束。检查后恢复原调用。

显式 types 不是唯一可行的路径。另一个对照实验是恢复原配置,把 dist/index.d.ts 改名为 dist/runtime.d.ts,并同步更新顶层 types。这样声明就能通过扩展名替换被找到。

本文最小实验 NodeNext ESM Bundler ESM
runtime.js 配 index.d.ts,无导出类型条件 TS7016 TS7016
增加指向 index.d.ts 的 types 条件 通过 通过
无导出类型条件,声明改为 runtime.d.ts 通过 通过

这组对照说明,不能把“缺少 exports.types”直接等同于“必然报 TS7016”。判断依据是消费者能否按当前规则找到声明。显式映射的好处是运行时和声明文件可以采用不同布局,入口关系也更容易审阅。

表中的结果使用 TypeScript 5.9.3 验证。Bundler 配置沿用同一消费者,仅把 module 改为 ESNext、moduleResolution 改为 Bundler。这项检查覆盖类型解析,没有验证某个具体构建器的打包行为。

用解析日志确认修复发生在哪里

遇到类似问题,可以在原消费者中先记录 TypeScript 版本和有效配置,再启用解析跟踪:

./node_modules/.bin/tsc --version
./node_modules/.bin/tsc -p tsconfig.nodenext.json --showConfig
./node_modules/.bin/tsc -p tsconfig.nodenext.json --traceResolution

日志通常很长,只需要定位目标包对应的片段:编译器启用了哪些条件,读取了哪个 exports 分支,尝试了哪些候选文件,最终选中了哪个路径。修复前后应能看到目标从 runtime.js 变成声明文件。不要只凭编辑器红线消失就结束检查。

如果命令行通过、编辑器仍报错,先确认编辑器使用的是同一份 TypeScript、同一份配置和同一个包版本。直接升级全部依赖会同时改变多个变量,让对照变得困难。

有几种办法看似能解决错误,实际绕过了原来的检查:

做法 实际效果
写空的 declare module "@example/text-kit"; 允许模块按 any 使用,失去原声明的约束
用 paths 直接指向内部声明 可能绕开包的公共入口,不能证明正常安装后可用
改成旧式模块解析 改变查找规则,可能掩盖与实际执行环境的差异
打开 skipLibCheck 跳过声明文件内部检查,不能修复入口声明不可达

第三方包暂时无法修复时,可以维护内容准确的本地声明作为兼容措施,但需要记录适用版本和移除条件。空模块声明只能消除部分报错,不能替代类型契约。

TS7016 还可能来自包完全没有声明等其他原因。本文讨论的是“声明已存在但入口不可达”这一类,不适合直接套用到所有同错误码问题。

支持 ESM 和 CJS 时,声明也要区分格式

前面的实验只提供 ESM。若一个包同时支持 import 和 require,还要核对两个运行时入口与各自声明的模块格式。

.mjs、.cjs 分别表示 ESM 和 CJS,声明文件对应 .d.mts、.d.cts。普通 .d.ts 的模块格式则受所属包的 type 影响。两个入口即使 API 名称相同,也不能未经检查就共享一份声明。TypeScript 双格式包声明说明

例如,双格式包可以采用下面的导出结构:

{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

这只是布局示意,必须同时提供对应文件,并让声明忠实描述每个入口的导出形态。module.exports = fn、exports.fn = fn 与 ESM 的默认导出、具名导出具有不同的消费方式,改文件扩展名不能替代这种核对。

测试时可以用 .mts 消费 ESM,用 .cts 配合 import = require() 消费 CJS,再分别执行运行时用例。只验证“声明路径找到了”,还无法证明导入到的值可以按声明使用。只承诺 ESM 的包则不必为了凑齐矩阵额外增加 CJS 产物。

发布检查要从打包后的文件开始

源码测试往往直接导入源文件,或者借助 workspace 链接和 paths 找到仓库中的声明。它们可能完全不经过最终发布包的入口,因而无法发现这类缺陷。

检查发布物时,先完成构建,再运行:

npm pack --ignore-scripts

--ignore-scripts 会跳过打包相关生命周期脚本,所以需要提前执行项目实际要求的构建和声明生成步骤。随后把生成的 .tgz 安装进独立消费者,使用包名导入,不链接源码目录,也不添加绕过入口的别名。

验收至少要区分以下几件事:

检查对象 要回答的问题
tarball 文件清单 声明入口及其引用的其他文件是否真的打进包?
每个公开入口 根入口和子路径是否都能在承诺的解析模式下获得类型?
类型约束 正确调用是否通过,错误参数是否被拒绝?
运行时或实际构建 声明中的导出是否存在,并能在目标环境使用?

一个子路径通过,不代表根入口通过;本地打包通过,也不代表 registry 中某个版本就是同一份产物。发布前检查待发布 tarball,发布后固定版本下载复验,并保留版本与完整性信息,才能让结果对应到消费者真正安装的文件。

防止这类缺陷回归,只需要一个小型消费者用例:独立安装打包文件,用支持的解析模式按包名导入,在严格检查下调用公开函数,并验证错误参数会被拒绝。让这项检查失败时阻断发布,就能在声明文件仍然存在、构建仍然成功的情况下,发现入口契约已经断开。