TypeScript
單元:型別宣告與整合(Type Declarations & Integration)
主題:Module Augmentation
簡介
在大型前端或 Node.js 專案中,我們常會使用第三方套件或框架(如 Express、Vue、Lodash)。這些套件的型別定義雖然已經相當完整,但在實際開發時仍會遇到 需要為既有模組加入自訂屬性或方法 的情況。直接改寫原始套件的 .d.ts 檔案既不安全,也會在升級套件時造成衝突。
TypeScript 提供的 module augmentation(模組擴充) 正是為了解決這類需求而設計的機制。透過宣告合併(declaration merging)與 declare module,我們可以在不觸碰原始程式碼的前提下,為既有模組「加屬性」或「補方法」。掌握這項技巧,能讓 TypeScript 在大型專案中保持型別安全,同時提升程式碼的可維護性與可擴充性。
以下將一步步說明 module augmentation 的核心概念、實作方式,以及在真實專案中的應用案例,並提供常見陷阱與最佳實踐,協助你從初學者順利過渡到中級開發者的行列。
核心概念
1. 什麼是 Module Augmentation
Module Augmentation(模組擴充)是指在 宣告階段 為已存在的外部模組(external module)或全域模組(global module)加入額外的型別資訊。它利用 宣告合併(declaration merging)的特性,讓多個 declare module 內容在編譯時自動合併成單一型別。
關鍵點:
- 只影響型別層面,不會改變執行時的程式碼。
- 必須在
.d.ts(或任何含有declare的檔案)中使用declare module。- 擴充的範圍僅限於 相同的模組名稱,因此名稱必須精確對應原始模組。
2. 為什麼需要 Augmentation
| 情境 | 直接修改原始 .d.ts |
使用 Module Augmentation |
|---|---|---|
| 第三方套件升級 | 需手動合併修改,容易遺漏 | 保持獨立檔案,升級無衝突 |
為 Express Request 加入 user |
侵入式,影響所有開發者 | 只在本專案內部擴充 |
| 為自訂工具函式庫加入 Helper 方法 | 需要改動原始檔案 | 可在專案根目錄新增 .d.ts 並自動合併 |
3. 基本語法
// 1. 先確定要擴充的模組名稱(以 npm 套件名稱或檔案路徑為主)
declare module "module-name" {
// 2. 在此內部宣告要加入的型別、介面或函式
export interface ExistingInterface {
newProperty: string;
}
export function newUtility(arg: number): void;
}
declare module "module-name":指定欲擴充的模組。- 內部的
export必須與原始模組的導出方式一致(export或export default)。 - 若要擴充 全域(如
NodeJS.Global),可使用declare global。
程式碼範例
範例 1️⃣:為 Express 的 Request 介面加入 user 屬性
// src/types/express.d.ts
import * as express from "express";
declare module "express-serve-static-core" {
// 直接擴充 core 中的 Request 介面
interface Request {
/** 目前已登入的使用者資訊,透過 middleware 注入 */
user?: {
id: string;
name: string;
role: "admin" | "member";
};
}
}
說明:
- 這裡使用
express-serve-static-core(Express 真正匯出的核心模組)而非"express",才能正確取得Request介面。- 增加的
user為可選屬性,避免在未登入時產生型別錯誤。
範例 2️⃣:擴充自訂模組 utils/math,加入 clamp 函式
// src/utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
// src/types/utils/math.d.ts
declare module "./utils/math" {
export function clamp(value: number, min: number, max: number): number;
}
// 使用範例
import { add, clamp } from "./utils/math";
const sum = add(3, 5); // 8
const limited = clamp(12, 0, 10); // 10
說明:
- 只需要在
.d.ts中宣告clamp,實作仍在原始檔案或其他檔案中。- 透過相對路徑
"./utils/math"指向目標模組,保持一致性。
範例 3️⃣:在全域環境下擴充 Window 物件
// src/types/window.d.ts
export {};
declare global {
interface Window {
/** 儲存全局的設定物件 */
__APP_CONFIG__: {
apiBaseUrl: string;
version: string;
};
}
}
// 任何檔案皆可直接使用
window.__APP_CONFIG__ = {
apiBaseUrl: "https://api.example.com",
version: "1.2.3",
};
說明:
- 使用
declare global讓擴充作用於全域Window介面。- 必須先
export {};讓檔案被視為模組,避免全域污染。
範例 4️⃣:為 Lodash 加入自訂的 isEven 方法
// src/types/lodash.d.ts
import "lodash";
declare module "lodash" {
/**
* 判斷傳入的數字是否為偶數
* @param n 任意數字
*/
function isEven(n: number): boolean;
}
// 使用方式
import _ from "lodash";
_.isEven(4); // true
_.isEven(7); // false
說明:
- 直接在
"lodash"模組上宣告新函式,TypeScript 會把它合併到原本的_物件中。- 若實作在別處(例如自訂插件),只要在執行時把函式掛到
_上即可。
範例 5️⃣:使用 declare module 合併命名空間與模組
// src/types/react.d.ts
import * as React from "react";
declare module "react" {
// 為 React.Component 添加自訂屬性
interface Component<P = {}, S = {}, SS = any> {
/** 用於測試的唯一標識 */
testId?: string;
}
}
// 範例元件
import React from "react";
class MyButton extends React.Component {
render() {
return <button testId="my-button">Click me</button>;
}
}
說明:
- 這種方式常用於 UI 測試框架,讓每個 React 元件都能攜帶
testId。
常見陷阱與最佳實踐
| 陷阱 | 可能的結果 | 解決方式 |
|---|---|---|
| 重複宣告同名介面 | 編譯錯誤 Duplicate identifier |
確認只在同一模組內宣告一次,或利用 namespace 包裝 |
忘記 export {}(讓檔案成為全域) |
擴充不會被視為模組,導致合併失效 | 在 .d.ts 開頭寫 export {}; |
模組路徑寫錯(使用 "express" 而非 "express-serve-static-core") |
擴充不到目標介面,型別仍顯示原始定義 | 參考原始套件的 index.d.ts,找出正確的模組名稱 |
在實作檔案中直接寫 declare module |
產生不必要的執行時代碼,可能導致程式碼被載入兩次 | 永遠把 augmentation 放在 .d.ts,保持純型別 |
tsconfig 沒有包含 .d.ts 檔案 |
編譯時找不到擴充,出現 Property does not exist 錯誤 |
確保 include 或 files 包含 src/types/**/*.d.ts |
最佳實踐
專門的型別目錄
- 建議在專案根目錄建立
types/(或@types/)資料夾,所有 augmentation 都放在這裡,方便管理與排除。
- 建議在專案根目錄建立
保持純型別
declare module只能出現在 宣告檔(.d.ts),切勿混入實作程式碼,以免產生副作用。
使用
export {}防止全域污染- 每個 augmentation 檔案的最上方加上
export {};,確保檔案被視為模組。
- 每個 augmentation 檔案的最上方加上
遵循單一職責原則
- 每個 augmentation 只針對一個具體需求(例如只擴充
Request.user),避免一次加入過多屬性,降低維護成本。
- 每個 augmentation 只針對一個具體需求(例如只擴充
配合
tsconfig.json的typeRoots- 若專案使用自訂型別目錄,記得在
tsconfig.json設定typeRoots: ["./node_modules/@types", "./types"],讓編譯器正確搜尋。
- 若專案使用自訂型別目錄,記得在
實際應用場景
1. Express 中的驗證 Middleware
在使用 JWT 或 Session 驗證時,通常會在 req 物件上掛上 user(或 session)資訊。若不透過 augmentation,TypeScript 會提示 Property 'user' does not exist on type 'Request'。透過前述的 範例 1,即可在整個專案內安全存取 req.user,並在 IDE 中得到自動補完與型別檢查。
2. Vue 3 插件的全域屬性
開發 Vue 3 插件時,常會在 app.config.globalProperties 上掛上自訂方法,如 $api。若要在 .vue 組件中直接使用 this.$api,必須為 Vue 的全域介面加上對應屬性:
// types/vue.d.ts
import { ComponentCustomProperties } from "vue";
declare module "@vue/runtime-core" {
interface ComponentCustomProperties {
$api: {
get<T>(url: string): Promise<T>;
post<T>(url: string, data: any): Promise<T>;
};
}
}
這樣即能在任何組件中寫 this.$api.get<User>('/me'),且型別安全。
3. 為第三方 UI 套件(如 Ant Design)加上自訂樣式屬性
有時候 UI 元件庫的 Props 定義過於嚴格,想要加入 data-test-id 之類的測試屬性。透過 module augmentation,直接在相應的 Props 介面上加入:
declare module "antd/lib/button" {
interface ButtonProps {
/** 測試時使用的唯一標識 */
"data-test-id"?: string;
}
}
這樣在 JSX 中使用 <Button data-test-id="login-btn" /> 時,IDE 不會再報錯。
總結
Module Augmentation 是 TypeScript 為 大型、可擴充專案 所提供的關鍵功能。它讓我們能在不改動原始套件的前提下,安全且彈性地為既有模組加入自訂型別,從而:
- 保持升級相容性
- 提升開發時的型別安全與自動補完
- 減少全域污染與維護成本
掌握正確的語法、遵循最佳實踐(獨立 .d.ts、使用 export {}、正確設定 tsconfig),即可在 Express、Vue、React、Lodash 等常見生態系中,快速完成型別擴充。未來無論是開發自訂 middleware、插件,或是為第三方函式庫加入測試屬性,module augmentation 都是不可或缺的利器。祝你在 TypeScript 的道路上,寫出更安全、更具可擴充性的程式碼!