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. 為什麼需要自動合併?
擴充第三方庫的型別
常見的情境是想為第三方函式庫(如 jQuery、Express)加入自訂屬性或方法,而不需要改動原始.d.ts檔。模組化開發
多個檔案負責同一介面的不同部份,透過合併可以保持程式碼的可讀性與維護性。全域宣告
在全域環境(例如瀏覽器)中,想把自訂屬性掛在Window、Document上,使用介面合併即可。
程式碼範例
以下示範 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 中設定 typeRoots、paths。 |
| 過度使用全域合併 | 把太多型別掛在全域會造成命名衝突與可讀性下降。 | 只在必要時(如第三方庫、環境變數)使用全域合併,其餘盡量使用 module augmentation(模組擴充)。 |
| enum 合併限制 | enum 只能在同一個「全域」或「同一模組」內合併,無法跨 ES 模組直接合併。 |
若需要跨模組擴充列舉,考慮改用 union string literal 或 const enum + 手動合併。 |
| IDE / lint 無法偵測 | 某些 IDE 可能在檔案未被 tsconfig.json 包含時無法提示合併結果。 |
確認所有 .d.ts 檔案都被 include 或 files 設定覆蓋;使用 tsc --noEmit 進行檢查。 |
最佳實踐小結:
- 分層管理:將自訂擴充放在
src/types/或@types/目錄,統一管理。 - 命名空間 vs 模組:盡量使用 module augmentation(
declare module 'xxx' { … })取代全域namespace,保持 ES 模組化特性。 - 文件說明:在每個擴充檔案的開頭加入註解,說明「為何」需要合併,避免未來維護時產生疑惑。
- 測試型別:使用
tsd或dtslint等工具寫型別測試,確保合併行為符合預期。
實際應用場景
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 的旅程中,寫出更乾淨、更可靠的程式碼!