Cannot find module './utils/format' or its corresponding type declarations.
我第一次被这个报错搞到崩溃,是在一个维护了大半年的中后台项目里。那天 CI 突然红了一片,本地的 npm run dev 和编辑器全部正常,只有打包机上的 tsc 死活过不去。我试过清缓存、删 node_modules、重装依赖,都没用。最后发现是某个依赖把 tsconfig 的 moduleResolution 从 node 改成了 bundler,解析行为一变,报错就全冒出来了。
这类问题的本质,就是 TypeScript 的模块解析机制。简单说,TypeScript 编译器在执行类型检查之前,必须先把每个 import 语句背后的文件找出来——是从当前目录找,还是去 node_modules 里翻;是优先找 .ts 还是 .d.ts;路径里的 @/ 前缀映射到哪个真实目录。这套查找规则,就是模块解析策略。
很多写了几年 TypeScript 的人,对这部分的理解停留在"报错了就乱改 tsconfig"的层面。我写这篇文章,是想把模块解析这件事从头到尾讲透:先讲经典策略的底层逻辑,再讲 paths 别名和 baseUrl 的来龙去脉,然后聊聊现代工具链下的新选择,最后分享我排查这类报错时的一套完整方法。不管你是刚上手 TS 的初级开发者,还是正在为一个大型项目做工程化配置的负责人,这篇文章里的内容应该都能直接用上。
1. 模块解析到底在解决什么问题:一次import背后的查找工作
1.1 为什么报错信息里总有 "or its corresponding type declarations"
这句报错其实是两个层面的信息。前一半 "Cannot find module './xxx'" 是编译器在说:按照当前的解析策略,我找不到满足这个导入路径的文件。后一半 "or its corresponding type declarations" 是补充:就算找不到源文件,你哪怕给我一个对应的 .d.ts 声明文件也行啊。
为什么需要声明文件?因为 TypeScript 做类型检查的时候,关心的是"这个模块暴露出来的类型是什么"。如果一个 JS 库本身没有类型声明,TS 就默认它是 any,但前提是至少能找到一个对应的声明来源。如果连声明都没有,编译器只能报错。所以这句报错里隐藏着一个事实:TS 找模块,不仅要找"能运行的代码",还要找"能描述类型的文件"。
真正坑人的地方在于,"找不到"在 TypeScript 里并不是一个确定的结果,它完全取决于解析策略。同样的代码,在 moduleResolution: "node" 下能编译,切到 "node16" 可能就报错;在 "classic" 下找不到,在 "bundler" 下可能又找到了。也就是说,模块解析不是一道"文件在不在"的问题,而是一道"你有没有按我(编译器)的规则去找"的问题。
1.2 模块解析策略:tsconfig 里的一个选项,背后的整套决策树
我在 Code Review 里经常看到这样的 tsconfig:
json复制{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "node"
}
}
很多人对 moduleResolution 的理解就是"选 node 就行了",但不知道这个选项背后是一整棵决策树。编译器拿到一个导入路径后,要依次回答几个问题:这个路径是相对路径还是非相对路径?有没有命中 paths 映射?导入文件所在的目录是什么?应该尝试哪些扩展名?要不要看 package.json 的 types 字段?如果在当前目录的 node_modules 里没找到,要不要往上翻一级?每一步答案不同,最终结果就完全不同。
所以模块解析策略说白了,就是 TS 团队预设好的几套"问答流程"。你用不同的策略,就是选择不同的流程。这也是为什么有些老的 npm 包在 node 模式下好好的,换到 node16 就"人间蒸发"。编译器不是简单地"打开文件看一眼",而是在执行一套固定的查找协议。
1.3 谁需要真正搞懂模块解析
说实话,如果你只是写写小脚本,或者项目里所有 import 都用相对路径,模块解析策略很少会出来找麻烦。但下面三类场景,没搞懂真的会翻车。
第一,搭新项目的时候。现在脚手架工具默认配置五花八门,有的用 bundler,有的用 nodenext,有的还是 node。如果你不理解它们之间的差异,项目跑起来全靠运气,甚至会出现"
