TypeScript 實務應用:Node.js + TypeScript 的模組解析策略(moduleResolution)
簡介
在使用 TypeScript 開發 Node.js 應用時,模組解析(module resolution) 是編譯器決定如何從 import / require 語句找到實際檔案的核心機制。若解析策略設定不當,常會出現找不到模組、路徑錯誤或編譯時間過長等問題,直接影響開發效率與程式的可維護性。
本篇文章針對 moduleResolution 這個 tsconfig.json 中的選項進行深入說明,從概念、常見設定、實作範例,到實務上的最佳實踐與陷阱,幫助你在 Node.js + TypeScript 專案中快速、正確地管理模組路徑。
核心概念
1. 為什麼需要模組解析策略?
TypeScript 必須在編譯階段把 import / export 轉換成 Node.js 能執行的 require 或 ES 模組語法。這個過程涉及兩件事:
- 找出檔案位置(相對路徑、絕對路徑或套件名稱)。
- 決定使用哪種檔案類型(
.ts、.tsx、.d.ts、.js、.json等)。
不同的解析策略會影響上述兩項行為的細節。
2. 兩大解析模式:classic vs node
| 模式 | 主要特徵 | 適用情境 |
|---|---|---|
| classic | 舊版 TypeScript 的預設行為,類似於 JavaScript 的相對路徑解析,僅支援 ./、../ 及裸檔名(不會自動搜尋 node_modules) |
老舊專案或需要自行實作模組搜尋邏輯的情況 |
| node | 依照 Node.js 的模組解析規則:先找相對/絕對路徑,找不到時會搜尋 node_modules、package.json 中的 main、exports 等 |
大多數 Node.js + TypeScript 專案的首選 |
註:在
tsconfig.json中未指定moduleResolution時,若module為commonjs、esnext等,編譯器會自動使用node;若module為amd、system,則使用classic。
3. 影響解析的其他設定
| 設定項目 | 作用說明 |
|---|---|
baseUrl |
設定非相對路徑的基礎目錄,讓 import "utils" 能從指定目錄開始搜尋。 |
paths |
為特定路徑別名(alias)提供映射表,常與 baseUrl 搭配使用。 |
rootDirs |
多根目錄的合併視圖,讓相同相對路徑在不同根目錄下的檔案互相參照。 |
typeRoots / types |
控制全域型別宣告的搜尋範圍,間接影響模組解析。 |
程式碼範例
以下示範在 Node.js + TypeScript 專案中,如何透過 tsconfig.json 正確設定模組解析,以及常見的使用情境。
1️⃣ 基本 node 解析(最常見)
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node", // 明確指定 node 解析
"outDir": "./dist",
"strict": true
},
"include": ["src"]
}
// src/server.ts
import express from "express"; // 會自動在 node_modules 中找 express
import { Router } from "./router"; // 相對路徑解析
const app = express();
app.use("/api", Router);
app.listen(3000, () => console.log("Server running"));
說明:
express透過node_modules/express/package.json中的main欄位指向express.js,編譯器即會正確解析。
2️⃣ 使用 baseUrl + paths 建立別名
// tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"baseUrl": "./src", // 所有非相對路徑都從 src 開始
"paths": {
"@utils/*": ["utils/*"], // @utils 會對映到 src/utils
"@models/*": ["models/*"]
}
}
}
// src/controllers/userController.ts
import { hashPassword } from "@utils/crypto"; // 由別名解析到 src/utils/crypto.ts
import { User } from "@models/user";
export async function createUser(name: string, pw: string) {
const hashed = await hashPassword(pw);
return new User(name, hashed);
}
技巧:別忘了在 webpack、ts-node 或 ESM 執行環境中同步設定相同的別名(例如
webpack.config.js的resolve.alias),否則執行時會找不到模組。
3️⃣ classic 解析的特殊情況
// tsconfig.json
{
"compilerOptions": {
"module": "amd",
"moduleResolution": "classic"
}
}
// src/legacy/moduleA.ts
export const hello = "world";
// src/main.ts
import { hello } from "moduleA"; // 只會在相同目錄或相對路徑下搜尋
重點:在
classic模式下,若使用裸模組名稱(如上例moduleA),編譯器不會去node_modules,必須自行確保檔案在相對路徑可被找到,否則會產生 TS2307: Cannot find module 錯誤。
4️⃣ 同時使用 rootDirs 處理 monorepo
// tsconfig.json(位於 monorepo 根目錄)
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"baseUrl": ".",
"rootDirs": ["packages/api/src", "packages/shared/src"]
}
}
// packages/api/src/service.ts
import { Logger } from "shared/logger"; // 透過 rootDirs,"shared/logger" 會映射到 packages/shared/src/logger.ts
說明:
rootDirs讓不同子專案之間的相對路徑保持一致,避免因實際檔案位置不同而產生錯誤。
5️⃣ paths 搭配 typeRoots 解決型別套件
// tsconfig.json
{
"compilerOptions": {
"moduleResolution": "node",
"baseUrl": "./src",
"paths": {
"@config": ["config/index"]
},
"typeRoots": ["./node_modules/@types", "./src/types"]
}
}
// src/config/index.ts
export const PORT = 3000;
// src/app.ts
import { PORT } from "@config"; // 別名解析
提示:若
@config所指的模組同時有自訂型別(.d.ts),請確保typeRoots包含其路徑,否則編譯器會顯示 implicit any 警告。
常見陷阱與最佳實踐
| 陷阱 | 可能的症狀 | 解決方式 |
|---|---|---|
忘記設定 moduleResolution: "node" |
在 commonjs 專案中,import "lodash" 失敗或找不到 node_modules |
明確在 tsconfig.json 加入 moduleResolution: "node",或確保 module 為 commonjs/esnext 時自動使用 |
| 別名與執行環境不同步 | 開發時編譯通過,執行時 Cannot find module '@utils/...' |
在 ts-node, Webpack, ESM 等工具中,同步設定相同的 alias / paths |
相對路徑與 baseUrl 混用導致重複 |
同一檔案被編譯兩次,產生多個 .js 檔 |
盡量統一使用 baseUrl + paths,或限制相對路徑只用於同一層級以下 |
paths 中使用 * 卻未對應 |
TS6053: File '/src/utils' not found |
確保 * 前後的通配符對應正確,例如 "@utils/*": ["utils/*"] |
Monorepo 中 rootDirs 設定不完整 |
交叉引用失敗、編譯錯誤 TS2307 |
在根 tsconfig.json 中列出所有子專案的 src 目錄,或使用 projectReferences 結合 composite 設定 |
最佳實踐
- 統一使用
node解析:除非有特殊需求,建議在 Node.js 專案中將moduleResolution設為"node",保持與執行環境一致。 - 以
baseUrl+paths建立別名:減少長相對路徑 (../../../) 的可讀性問題,並在 IDE 中自動補全。 - 在 CI/CD 中驗證別名:使用
tsc --noEmit或npm run lint確認所有別名在編譯階段皆能正確解析。 - 結合
projectReferences:大型 monorepo 建議使用 TypeScript 3.0+ 的references功能,讓每個子套件都有自己的tsconfig.json,同時在根目錄設定moduleResolution: "node"。 - 同步工具設定:Webpack、Rollup、Vite、ts-node、nodemon 等工具皆需要對應的別名設定,否則會在執行時拋出 MODULE_NOT_FOUND。
實際應用場景
1️⃣ 建立大型後端服務的模組別名
在一個以 Clean Architecture 為核心的微服務中,會有 src/domain, src/application, src/infrastructure 等層級。若直接使用相對路徑:
import { UserRepository } from "../../../infrastructure/repositories/userRepository";
會讓程式碼變得雜亂且易出錯。透過 paths:
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@domain/*": ["domain/*"],
"@app/*": ["application/*"],
"@infra/*": ["infrastructure/*"]
}
}
}
即可寫成:
import { UserRepository } from "@infra/repositories/userRepository";
2️⃣ 多語系前端/後端共用型別
在同一 monorepo 中,前端(React)與後端(Node)共享 src/types/api.d.ts。使用 rootDirs:
{
"compilerOptions": {
"rootDirs": ["packages/frontend/src", "packages/backend/src"]
}
}
讓兩側都能以相同的相對路徑引用:
import { ApiResponse } from "shared/types/api";
3️⃣ 以 moduleResolution: classic 處理舊版 AMD 模組
某些遺留系統仍使用 AMD(RequireJS)載入模組,且模組名稱已寫死在 HTML 中。此時必須保留 classic 解析,並在 tsconfig.json 明確指定:
{
"compilerOptions": {
"module": "amd",
"moduleResolution": "classic"
}
}
配合 paths 可把舊有的裸模組名稱映射到新位置:
{
"paths": {
"legacy/util": ["legacy/src/util"]
}
}
總結
- moduleResolution 決定了 TypeScript 如何在編譯階段定位模組檔案,
node為大多數 Node.js + TypeScript 專案的首選。 - 配合 baseUrl、paths、rootDirs 等設定,我們可以建立乾淨、可維護的模組別名,避免長相對路徑帶來的閱讀與維護負擔。
- 常見的陷阱包括別名未同步至執行環境、
classic解析導致的模組找不到,以及在 monorepo 中rootDirs設定不完整。透過 最佳實踐(統一解析模式、同步工具設定、CI 驗證)可以有效降低這些風險。 - 在實務上,無論是大型微服務、前後端共用型別,或是遺留的 AMD 系統,正確的模組解析策略都是保持專案可擴展、開發效率的關鍵。
掌握了 moduleResolution 與相關的 tsconfig 設定後,你就能在 Node.js + TypeScript 的開發旅程中更加得心應手,快速構建高品質、易維護的程式碼基礎。祝你寫程式愉快! 🚀