本文 AI 產出,尚未審核

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 必須與原始模組的導出方式一致(exportexport 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 錯誤 確保 includefiles 包含 src/types/**/*.d.ts

最佳實踐

  1. 專門的型別目錄

    • 建議在專案根目錄建立 types/(或 @types/)資料夾,所有 augmentation 都放在這裡,方便管理與排除。
  2. 保持純型別

    • declare module 只能出現在 宣告檔.d.ts),切勿混入實作程式碼,以免產生副作用。
  3. 使用 export {} 防止全域污染

    • 每個 augmentation 檔案的最上方加上 export {};,確保檔案被視為模組。
  4. 遵循單一職責原則

    • 每個 augmentation 只針對一個具體需求(例如只擴充 Request.user),避免一次加入過多屬性,降低維護成本。
  5. 配合 tsconfig.jsontypeRoots

    • 若專案使用自訂型別目錄,記得在 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 的道路上,寫出更安全、更具可擴充性的程式碼!