TypeScript 模組與命名空間:深入了解 module augmentation(模組擴充)
簡介
在大型前端或 Node.js 專案中,我們常會使用第三方套件(如 express、lodash、moment)或是自行切分功能模組。模組擴充(module augmentation)是 TypeScript 提供的一項強大機制,允許開發者在不修改原始程式碼的前提下,為既有模組或全域宣告加入額外的型別、函式或屬性。
透過模組擴充,我們可以:
- 為第三方函式庫補足缺失的型別定義(例如舊版套件沒有提供完整的型別)
- 為自家共用模組加入「插件」式的功能,保持核心程式碼的乾淨與可維護
- 在不同的程式碼基底中使用 宣告合併(declaration merging)來形成更彈性的 API
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者一步一步掌握模組擴充的使用方式,適用於 初學者到中級開發者。
核心概念
1. 為什麼需要模組擴充?
在 TypeScript 中,模組的型別資訊通常來自 .d.ts 宣告檔或是直接在程式碼中寫好的 export。當我們想要 在外部加入新屬性 時(例如在 express.Request 上加上自訂的 user 欄位),直接修改原始檔案既不切實際,也會造成升級衝突。模組擴充提供了一個 「外掛」 的方式,讓我們在自己的檔案中「補齊」或「延伸」既有模組的型別。
2. 基本語法
模組擴充的核心是 declare module "module-name",在此區塊內可以:
- 擴充介面(interface):加入新屬性或方法
- 擴充類別(class):加入 prototype 方法(需要使用
declare global) - 擴充命名空間(namespace):加入額外的函式或常數
- 擴充函式(function):使用 module augmentation 為已有函式加入 overload
範例框架:
// my-augmentation.ts
import "original-module"; // 必須先 import 原始模組,使 TypeScript 知道它的存在
declare module "original-module" {
// 在此處寫入想要擴充的型別
}
⚠️ 注意:
declare module必須使用 字串字面量(module name),而不是變數或相對路徑。
3. 常見的擴充方式
以下分別示範四種實務上最常見的情境。
3.1 為第三方函式庫的介面加入自訂屬性(以 Express 為例)
// express-augmentation.ts
import "express";
declare module "express-serve-static-core" {
// Express 的 Request 介面其實在 express-serve-static-core 中定義
interface Request {
/** 目前登入的使用者資訊,已在驗證 middleware 中寫入 */
user?: {
id: string;
name: string;
roles: string[];
};
}
}
說明:
- 先
import "express"讓 TypeScript 載入原始型別。- 使用
declare module "express-serve-static-core"(這是 Express 真正定義Request的模組)。- 在
interface Request中加入user?屬性,之後所有req物件都會自動擁有此屬性,IDE 也會正確提示。
3.2 為第三方函式庫新增全域函式(以 Lodash 為例)
// lodash-augmentation.ts
import _ from "lodash";
declare module "lodash" {
interface LoDashStatic {
/** 取得陣列中唯一值的次序索引 */
uniqIndices<T>(array: T[]): number[];
}
}
// 實作實際的函式(必須在同一檔案或另一個 .ts檔裡)
_.uniqIndices = function <T>(array: T[]): number[] {
const seen = new Map<T, number>();
const indices: number[] = [];
array.forEach((item, idx) => {
if (!seen.has(item)) {
seen.set(item, idx);
indices.push(idx);
}
});
return indices;
};
說明:
LoDashStatic為 lodash 的全域物件類型,我們在裡面加上uniqIndices方法的型別宣告。- 接著直接把實作掛到
_上,讓程式碼在執行時能呼叫_.uniqIndices([...])。
3.3 為自訂模組加入「插件」功能(以 utils 為例)
假設有一個共用工具模組 utils.ts:
// utils.ts
export function formatDate(date: Date, fmt: string): string {
// 簡易實作...
return "";
}
現在想在別的檔案中為 utils 加入 parseDate:
// utils-augmentation.ts
import * as utils from "./utils";
declare module "./utils" {
export function parseDate(str: string, fmt: string): Date;
}
// 實作
export function parseDate(str: string, fmt: string): Date {
// 依照 fmt 解析日期字串
return new Date(str);
}
說明:
declare module "./utils"必須使用相對路徑(相對於目前檔案)。- 在擴充區塊中 重新 export 新增的函式。
- 實作部分可以直接寫在同一檔或分離,只要確保檔案被編譯即可。
3.4 為全域型別(global)加入宣告(以 Node.js 為例)
有時候我們需要在所有檔案裡都能使用自訂的全域變數:
// global-augmentation.d.ts
export {};
declare global {
/** 應用程式的全域設定 */
const APP_CONFIG: {
env: "development" | "production";
version: string;
};
}
在任何 .ts 檔案中即可直接使用:
console.log(`Running ${APP_CONFIG.version} in ${APP_CONFIG.env}`);
說明:
- 必須先
export {};讓檔案被視為模組,才能使用declare global。- 這樣的寫法相當於把變數掛到
globalThis,但在編譯階段會得到完整的型別提示。
4. 模組擴充與宣告合併(Declaration Merging)
TypeScript 的 宣告合併 允許同名的 interface、namespace、enum 等在不同檔案中自動合併。模組擴充正是利用這個機制:
// a.ts
export interface Settings {
theme: "light" | "dark";
}
// b.ts
declare module "./a" {
interface Settings {
language: string; // 合併至 Settings
}
}
最終 Settings 會同時擁有 theme 與 language 兩個屬性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 忘記先 import 原始模組 | 若未先 import "module",編譯器找不到原始型別,擴充會失敗。 |
在檔案頂部 先 import(即使不使用任何匯出),確保模組已被載入。 |
| 模組名稱寫錯 | declare module 必須使用正確的 字串字面量(例如 "express-serve-static-core"),寫成相對路徑會找不到。 |
查閱套件的 .d.ts 檔或官方文件,確認實際的模組名稱。 |
| 擴充的型別與原始型別不相容 | 例如在介面中加入必填屬性,會導致原本的實作無法符合新型別。 | 使用可選屬性 (?) 或 新增方法(不改變原有結構)。 |
| 重複宣告造成衝突 | 多個檔案同時擴充同一屬性,可能產生「重複定義」錯誤。 | 統一管理擴充檔案,或使用 namespace 內部的子介面分層。 |
未將擴充檔案加入 tsconfig.json |
若檔案不在 include 或 files 範圍內,編譯時不會看到擴充。 |
確認 tsconfig.json 中的 include 包含所有 *.d.ts 或 *.ts 的擴充檔案。 |
最佳實踐
- 集中管理:將所有擴充放在
src/types/或typings/目錄,統一index.d.ts入口。 - 保持擴充為可選:盡量使用
?或 overload,避免破壞原有程式碼的相容性。 - 寫測試:對於新增的介面屬性或函式,寫單元測試確保在執行時不會出現
undefined。 - 文件化:在專案 README 或 Wiki 中說明哪些套件被擴充、擴充的目的與使用方式,方便新成員快速上手。
實際應用場景
| 場景 | 為什麼適合使用模組擴充 | 範例 |
|---|---|---|
| Express 中的驗證資訊 | 多個路由都需要存取 req.user,而官方 Request 沒有此屬性。 |
參考 3.1 的 express-augmentation.ts。 |
| 為第三方 UI 套件加入自訂主題屬性 | UI 套件如 antd 提供 ConfigProvider,但公司有自訂的 theme 設定。 |
declare module "antd/lib/config-provider",在 ConfigProviderProps 加入 customTheme?: ThemeConfig。 |
| 在 lodash 加入專案專屬工具 | 團隊常用的 uniqIndices 在 lodash 中不存在,卻想保持統一的工具呼叫方式。 |
參考 3.2 的 lodash 擴充。 |
| Node.js 全域環境變數 | 為了避免在每個檔案都 import config,直接掛在全域。 |
參考 3.4 的 global-augmentation.d.ts。 |
| 插件式的功能擴充 | 大型 SaaS 平台需要支援第三方插件,每個插件會在核心模組上掛載額外方法。 | 透過 declare module "./core" 為核心模組加入 registerPlugin(plugin: Plugin)。 |
總結
- module augmentation 是 TypeScript 提供的「外掛」機制,讓我們能在不改動原始程式碼的情況下,為既有模組或全域環境加入型別、函式與屬性。
- 基本語法是
declare module "module-name",配合 import、interface 合併、global 宣告 等技巧即可完成。 - 實務上常見於 Express request 擴充、第三方函式庫功能增補、全域設定、以及 插件式架構。
- 使用時要避免忘記
import、模組名稱錯誤、以及不相容的必填屬性;同時遵守 集中管理、可選屬性、寫測試與文件化 的最佳實踐,可讓專案維護成本大幅降低。
掌握了模組擴充,你就能在大型 TypeScript 專案中,像拼圖般靈活地為任何模組加上自訂功能,提升程式碼的可讀性與可維護性。祝開發順利,玩得開心! 🎉