本文 AI 產出,尚未審核

TypeScript 基礎概念:型別檔自動引入(Declaration Merging)


簡介

在大型前端或 Node.js 專案中,型別宣告往往散布在多個檔案、套件或第三方庫裡。若每次都需要手動 import/// <reference …>,不僅程式碼雜亂,也容易遺漏導入,導致編譯錯誤。
TypeScript 提供的 Declaration Merging(型別檔自動合併) 能讓多個相同名稱的型別、介面、命名空間自動合併成一個完整的型別,讓開發者只需要關注「宣告」本身,而不必擔心匯入的細節。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者一步步掌握 Declaration Merging,並了解它在實務專案中的應用方式。


核心概念

1. 什麼是 Declaration Merging?

Declaration Merging 指的是 當 TypeScript 在同一作用域(global、module、namespace)內,遇到多個相同名稱的宣告時,會自動將它們合併成一個完整的型別。合併的對象包括:

類型 合併方式
interface 成員會被「串接」在一起,重複的屬性會被合併(如果型別相容)
type alias 只能在同一行使用交叉類型 (&) 手動合併,不能自動合併
namespace 內部成員會被合併,形成單一的命名空間物件
enum 成員會被合併成同一個列舉(只能在全域或同一模組內)
function overloads 多個同名函式會被視為重載(overload)

注意:合併僅在 同一個「全域」或「相同模組」 內發生。若分散在不同的 ES 模組(import/export)中,必須先透過 import 把它們帶入同一作用域。


2. 為什麼需要自動合併?

  1. 擴充第三方庫的型別
    常見的情境是想為第三方函式庫(如 jQuery、Express)加入自訂屬性或方法,而不需要改動原始 .d.ts 檔。

  2. 模組化開發
    多個檔案負責同一介面的不同部份,透過合併可以保持程式碼的可讀性與維護性。

  3. 全域宣告
    在全域環境(例如瀏覽器)中,想把自訂屬性掛在 WindowDocument 上,使用介面合併即可。


程式碼範例

以下示範 5 個常見且實用的合併情境,皆以 JavaScript/TypeScript 為語言,並加上詳細註解說明。

範例 1️⃣:擴充全域 Window 介面

// global.d.ts
interface Window {
  /** 自訂的全域變數,用於儲存使用者設定 */
  __APP_CONFIG__: { theme: string; language: string };
}

// app.ts
window.__APP_CONFIG__ = { theme: 'dark', language: 'zh-TW' };

console.log(window.__APP_CONFIG__.theme); // => "dark"

說明:只要在任何 .d.ts 檔案中再宣告一次 interface Window,TypeScript 會自動把兩個介面合併,讓 window.__APP_CONFIG__ 成為合法屬性。


範例 2️⃣:為第三方函式庫 express 增加自訂屬性

// express.d.ts (自訂擴充檔)
import * as express from 'express';

declare global {
  namespace Express {
    /** 讓每個 Request 物件都帶有 userId */
    interface Request {
      userId?: string;
    }
  }
}

// route.ts
import express from 'express';
const router = express.Router();

router.get('/profile', (req, res) => {
  // 透過前置中介軟體設定的 userId
  const uid = req.userId ?? 'anonymous';
  res.send(`User ID: ${uid}`);
});

說明:透過 declare global + namespace Express,把 Request 介面與原本的 Express 定義合併,讓我們在程式碼中直接使用 req.userId


範例 3️⃣:合併多個 interface 形成完整的模型

// user-base.ts
export interface UserBase {
  id: number;
  name: string;
}

// user-contact.ts
export interface UserBase {
  email: string;
  phone?: string;
}

// user.ts
import { UserBase } from './user-base';
// 只要在同一個模組內,兩個介面會合併成以下形狀
const user: UserBase = {
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
  // phone 可省略
};

說明UserBase 介面在兩個檔案中分別宣告,編譯後會自動合併成包含 id、name、email、phone 四個屬性的完整型別。


範例 4️⃣:命名空間(namespace)合併

// utils-math.ts
namespace Utils {
  export function add(a: number, b: number) {
    return a + b;
  }
}

// utils-string.ts
namespace Utils {
  export function capitalize(str: string) {
    return str.charAt(0).toUpperCase() + str.slice(1);
  }
}

// main.ts
/// <reference path="./utils-math.ts" />
/// <reference path="./utils-string.ts" />

console.log(Utils.add(2, 3));          // 5
console.log(Utils.capitalize('ts'));  // "Ts"

說明:兩個 namespace Utils 會被合併成同一個命名空間,裡面的函式可以互相呼叫,且在全域中只會產生一個 Utils 物件。


範例 5️⃣:列舉(enum)合併(全域或同模組內)

// status-part1.ts
enum Status {
  Pending = 'PENDING',
  Approved = 'APPROVED',
}

// status-part2.ts
enum Status {
  Rejected = 'REJECTED',
}

// usage.ts
/// <reference path="./status-part1.ts" />
/// <reference path="./status-part2.ts" />

function handle(s: Status) {
  switch (s) {
    case Status.Pending:
    case Status.Approved:
    case Status.Rejected:
      console.log('Handled:', s);
  }
}
handle(Status.Rejected); // "Handled: REJECTED"

說明enum Status 在兩個檔案中分別宣告,會自動合併成包含三個成員的列舉。注意:enum 合併只能在同一個模組或全域作用域內使用,若使用 ES 模組則必須先 import


常見陷阱與最佳實踐

陷阱 說明 解決方式 / Best Practice
重複屬性型別不相容 兩個介面同名屬性型別不一致(如 string vs number)會產生錯誤。 盡量保持屬性型別相容;若必須不同,可使用 交叉類型 (type A = B & C) 取代自動合併。
模組作用域混淆 在 ES 模組中宣告 interface X,若未 import,合併不會生效。 使用 declare global 或將擴充檔放在同一模組內;或在 tsconfig.json 中設定 typeRootspaths
過度使用全域合併 把太多型別掛在全域會造成命名衝突與可讀性下降。 只在必要時(如第三方庫、環境變數)使用全域合併,其餘盡量使用 module augmentation(模組擴充)。
enum 合併限制 enum 只能在同一個「全域」或「同一模組」內合併,無法跨 ES 模組直接合併。 若需要跨模組擴充列舉,考慮改用 union string literalconst enum + 手動合併。
IDE / lint 無法偵測 某些 IDE 可能在檔案未被 tsconfig.json 包含時無法提示合併結果。 確認所有 .d.ts 檔案都被 includefiles 設定覆蓋;使用 tsc --noEmit 進行檢查。

最佳實踐小結

  1. 分層管理:將自訂擴充放在 src/types/@types/ 目錄,統一管理。
  2. 命名空間 vs 模組:盡量使用 module augmentationdeclare module 'xxx' { … })取代全域 namespace,保持 ES 模組化特性。
  3. 文件說明:在每個擴充檔案的開頭加入註解,說明「為何」需要合併,避免未來維護時產生疑惑。
  4. 測試型別:使用 tsddtslint 等工具寫型別測試,確保合併行為符合預期。

實際應用場景

1. 為第三方 UI 庫(如 Ant Design)加入自訂屬性

// ant-design.d.ts
import 'antd/lib/button';

declare module 'antd/lib/button' {
  export interface ButtonProps {
    /** 自訂的 loading 文字 */
    loadingTip?: string;
  }
}

// 在組件中直接使用
import { Button } from 'antd';
<Button loading loadingTip="資料載入中...">送出</Button>

場景說明:公司內部 UI 規範要求所有 Button 在 loading 時顯示提示文字,透過 module augmentation 可無痛擴充原本的 ButtonProps

2. 在微服務專案中共用錯誤型別

// errors-base.d.ts
export interface AppError {
  code: string;
  message: string;
}

// errors-auth.d.ts
import { AppError } from './errors-base';

export interface AppError {
  /** 只在認證錯誤時出現 */
  authFailed?: boolean;
}

// 任何微服務都可以 import { AppError },自動得到完整屬性

場景說明:多個服務共享基礎錯誤型別,個別服務再自行擴充特有欄位,維持一致性又不失彈性。

3. 為 Node.js process.env 定義嚴格型別

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    /** 伺服器埠號 */
    PORT: string;
    /** 是否為正式環境 */
    NODE_ENV: 'development' | 'production';
  }
}

// 使用時可得到自動完成與型別檢查
const port = Number(process.env.PORT);
if (process.env.NODE_ENV === 'production') { /* ... */ }

場景說明:透過介面合併,讓環境變數在開發時即享有 IDE 補完與型別安全,避免因打錯變數名而產生執行時錯誤。


總結

  • Declaration Merging 是 TypeScript 為了解決多檔案型別擴充、全域宣告與第三方庫客製化而提供的強大機制。
  • 它支援 interface、namespace、enum 等結構的自動合併,讓開發者可以在不改動原始檔案的前提下,靈活地為型別加上額外屬性或方法。
  • 使用時須注意 作用域、型別相容性 以及 全域污染 的風險,並遵循 分層管理、模組化擴充、型別測試 的最佳實踐。
  • 在實務中,從 擴充第三方套件統一錯誤模型、到 環境變數型別化,Declaration Merging 都能提升程式碼的可讀性、可維護性與安全性。

掌握了 Declaration Merging 後,你將能更自在地在大型 TypeScript 專案中 「自動引入」 所需型別,讓開發流程更順暢、錯誤更早被捕捉。祝你在 TypeScript 的旅程中,寫出更乾淨、更可靠的程式碼!