这节在地图里是工程化的地基。很多"明明文件在那却找不到模块"的报错,根子在 module 与 moduleResolution 这两个开关没配对。我们直接讲清它们怎么决定 import 的写法。
TS 同时支持 import/export(ESM)和 require(CommonJS)。编译成哪种,由 module 决定。
// ESM 写法(推荐) import { foo } from "./foo"; export const bar = 1; // CommonJS 写法(老 Node 项目) import foo = require("./foo"); export = bar;
输入输出:配 module: "ESNext" 时输出原生 import;配 module: "CommonJS" 时输出 require。选错会导致 Node 跑不起来或浏览器不认。
moduleResolution 告诉 TS 如何把 import "./foo" 映射到具体文件。常用三档:
// 经典 Node 解析(CommonJS 时代) { "module": "CommonJS", "moduleResolution": "node" } // 现代 Node ESM(Node 12+) { "module": "ESNext", "moduleResolution": "node16" } // 打包器场景(Vite/webpack,支持扩展名省略与别名) { "module": "ESNext", "moduleResolution": "bundler" }
易错点:moduleResolution: "node" 要求写 import "./foo" 时文件是 foo.ts,但运行时 Node ESM 要 ./foo.js。用 node16/bundler 能免去这层心智负担。
深层目录里 ../../../utils 既丑又易错。用 paths + baseUrl 起别名。
// tsconfig.json { "compilerOptions": { "baseUrl": ".", "paths": { "@app/*": ["src/app/*"], "@shared/*": ["src/shared/*"] } } }
// 之前 import { calc } from "../../../shared/calc"; // 之后 import { calc } from "@shared/calc";
注意:paths 只帮 TS 找类型,运行时(Node/打包器)也要配对应别名,否则编译过、运行崩。这张图把"解析链条"画出来:

一个库想既被 ESM 又被 CommonJS 用户 import,需两种产物 + 对应 package.json 的 exports 字段。TS 侧用 moduleResolution: "node16" 让它按文件扩展名(.mts/.cts)和 package.json 决定类型来源。
// package.json 关键片段 { "type": "module", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } }
.d.ts 与环境声明模块解析不仅找实现,也找类型。没有类型的 JS 模块,用 declare module 补一份环境声明(ambient declaration),让 TS 认识它。
// legacy.d.ts:给无类型的老模块补契约 declare module "legacy-lib" { export function run(opts: { timeout: number }): void; export const version: string; } // 之后普通 import 即可获得类型 import { run, version } from "legacy-lib"; run({ timeout: 1000 }); // 有提示
背景:大量 npm 旧包没自带类型。操作:写 .d.ts 用 declare module 描述导出形状。结果:项目内 import 获得完整检查。解读:.d.ts 是"只含类型、无运行时代码"的文件,编译后不产生任何 JS,是连接 TS 与 JS 生态的桥。
前端常 import logo from "./logo.png" 这类非代码资源。编译器不认,需要一条"通配符模块声明"告诉它这种导入返回什么类型。
// assets.d.ts declare module "*.png" { const src: string; export default src; } declare module "*.svg" { const content: string; export default content; } // 之后 import logo from "./logo.png"; // logo 被推断为 string
背景:打包器(Vite/webpack)实际会把图片处理成 URL 字符串。操作:用 *.png 通配符 declare module 声明默认导出类型。结果:TS 不再报"找不到模块"。解读:这把"资源也是一种模块"纳入类型系统,避免到处 // @ts-ignore 破坏契约。
export * 能聚合多个模块,但类型与值会一起被转发。想只转发类型,用 export type。
// 只转发类型,不引入运行时代码 export type { User, Order } from "./models"; // 值或类型都转发 export * from "./utils";
背景:库想按需暴露类型而不增加运行时体积。操作:export type 仅转发类型。结果:树摇更干净,消费方也不会因只引类型而被迫打包无关代码。解读:类型与值的边界在模块化里也重要——export type 让"只取形状"成为显式意图。
新项目直接 ESM + moduleResolution: bundler(前端)或 node16(Node)。维护老 Node 库时保留 CommonJS 更稳,避免升级惊动所有调用方。解析策略跟着 module 走,别混搭。
有时你 import 一个模块只为它的类型或它的副作用(如注册插件),TS 都支持清晰写法。
// 只取类型,编译后完全消失(不占产物) import type { Plugin } from "./plugin"; // 只跑副作用,不绑定任何名字 import "./polyfill"; // 混合:值 + 类型 分开写,tree-shaking 更友好 import { setup } from "./runtime"; import type { Config } from "./runtime"; const p: Plugin = { name: "x" }; // Plugin 仅编译期,运行时无此引用 setup(); // 运行期调用
输入输出:import type 的 Plugin 在产物里被彻底抹去,不会因"只用了类型"而把整个模块打进包。这比 import { Plugin } 再也不用值,更利于打包器摇树。
遇到"找不到模块",报错码能告诉你根因,别只盯着路径。
import { x } from "./missing"; // 常见报错: // TS2307: 找不到模块"./missing"或其相应的类型声明。 // → moduleResolution 配错,或文件真不存在,或扩展名/别名未配 // TS2829: 无法从"./x.json"导入,需设 resolveJsonModule // → 读 JSON 要开 resolveJsonModule
背景:新人常把 TS2307 当成"路径写错"。操作:先确认 moduleResolution 与文件扩展名是否匹配(如 node16 要 .js)。结果:多数"找不到"其实是解析策略与运行时不一致。解读:解析是"类型世界"和"文件系统"的桥,桥的规格由 module/moduleResolution 共同决定。
module 决定输出语法,moduleResolution 决定如何找文件node/node16/bundler 三档对应不同生态paths 别名改善可读性,但要运行时同步配置exports 字段 + 对应解析策略⚠️ 别以为 paths 配了就万事大吉。它只影响编译期类型查找,运行时 Node 不认 @shared/...,不配打包器或 tsconfig-paths 会运行期崩溃。
💡 新项目前端用 moduleResolution: bundler,能省略扩展名、支持别名,和 Vite/webpack 最契合;Node 库用 node16 才能正确处理 ESM 与 CJS 双形态。