本文 AI 產出,尚未審核

TypeScript – 物件與介面(Objects & Interfaces)

主題:物件型別合併(Declaration Merging)


簡介

在大型前端專案或 Node.js 應用程式中,我們常會碰到 型別需要隨需求不斷擴充 的情況。若每次都必須重新建立或覆寫介面,程式碼會變得冗長且易出錯。TypeScript 提供的 物件型別合併(Declaration Merging),讓多個相同名稱的 interfacetypenamespacemodule 能自動合併成單一型別,從而達到「增量式」擴充的效果。

透過宣告合併,我們可以:

  • 保持型別定義的單一責任,讓每個檔案只關心自己的部份。
  • 避免重複定義,減少維護成本。
  • 與第三方套件或全域宣告 無縫結合,提升開發彈性。

本篇文章將從概念說明、實作範例、常見陷阱到實務應用,完整介紹宣告合併的使用方式,幫助你在日常開發中更得心應手。


核心概念

1. 什麼是 Declaration Merging?

Declaration Merging(宣告合併)是指 當 TypeScript 在同一作用域(global、module、namespace)內偵測到多個相同名稱的宣告時,會自動將它們合併為單一的型別。合併的結果取決於宣告的類型(interfaceenumnamespace 等),合併規則如下:

宣告類型 合併結果
interface 成員會 相加(屬性、方法、索引簽名皆會合併)
enum 成員會 相加,且不可重複
namespace 內部的所有成員(變數、函式、類別)會 相加
class + interface class實作(implement)合併後的介面

重點:合併只發生在同一個 命名空間(global、module、namespace)內,跨檔案時必須使用 export / import/// <reference /> 讓編譯器知道它們屬於同一個範圍。


2. 介面(Interface)合併

範例 1:基本介面合併

// file: userBase.ts
export interface User {
  id: number;
  name: string;
}

// file: userDetail.ts
export interface User {
  email: string;
  role?: 'admin' | 'member';
}

合併結果

// 產生的單一介面
interface User {
  id: number;
  name: string;
  email: string;
  role?: 'admin' | 'member';
}

說明:兩個 User 介面在同一模組內被 export,編譯器會自動把屬性合併。使用 User 時,所有屬性皆可取得。

範例 2:方法合併與衝突

interface Logger {
  log(message: string): void;
}

// 另一個檔案
interface Logger {
  // 同名方法會 **合併簽名**,形成 overload
  log(message: string, level: 'info' | 'warn' | 'error'): void;
}

使用方式

const consoleLogger: Logger = {
  log(msg: string): void {
    console.log(msg);
  },
  log(msg: string, level: 'info' | 'warn' | 'error'): void {
    console[level](msg);
  },
};

consoleLogger.log('系統啟動');               // 呼叫第一個 overload
consoleLogger.log('發生錯誤', 'error');   // 呼叫第二個 overload

注意:若同名屬性(非方法)出現型別衝突,編譯會報錯;但方法會自動形成 overload(重載)。

範例 3:索引簽名合併

interface Config {
  [key: string]: string;
}

// 另一個檔案
interface Config {
  version: string;
}

結果Config 同時擁有字串索引簽名與明確屬性 version,符合 TypeScript 的結構相容性。


3. Namespace(命名空間)合併

範例 4:擴充第三方套件的型別

假設我們使用 express,想在 Request 介面上加入自訂屬性 userId

// 在專案的 typings.d.ts 中
import * as express from 'express';

declare module 'express-serve-static-core' {
  interface Request {
    userId?: number;   // 新增屬性
  }
}

合併結果express 原本的 Request 介面與我們的擴充會自動合併,所有使用 Request 的地方皆能取得 userId

範例 5:Namespace 與 Function 合併

namespace MathUtil {
  export function add(a: number, b: number) {
    return a + b;
  }
}

// 另一個檔案
namespace MathUtil {
  export function mul(a: number, b: number) {
    return a * b;
  }
}

// 使用
console.log(MathUtil.add(2, 3)); // 5
console.log(MathUtil.mul(2, 3)); // 6

技巧:利用 namespace 合併,我們可以把相關工具函式分散在多個檔案中,最後仍以同一個命名空間對外暴露。


4. Enum 合併

範例 6:擴充列舉值

enum Status {
  Ready = 'ready',
}

// 另一個檔案
enum Status {
  Loading = 'loading',
  Finished = 'finished',
}

合併結果

enum Status {
  Ready = 'ready',
  Loading = 'loading',
  Finished = 'finished',
}

限制:列舉值不可重複,若重複會產生編譯錯誤。


常見陷阱與最佳實踐

陷阱 說明 解決方式
同名屬性型別衝突 interface A { foo: string }interface A { foo: number } 會錯誤。 確保擴充時 僅加入新屬性或相容的型別(如子型別)。
跨模組合併失效 若兩個檔案分別 export 且沒有共同的 import/reference,編譯器無法判斷同屬同一模組。 使用 export + import 或在 tsconfig.json 中設置 typeRootspaths,確保檔案被同一個模組解析。
全域宣告污染 在全域 declare global 中大量合併會影響其他套件的型別推斷。 盡量使用模組化export/import),僅在必要時才使用全域擴充。
Enum 合併重複值 重複的列舉成員會造成編譯錯誤。 檢查列舉值唯一性,或改用 const enum + 合併檔案。
函式 overload 不一致 合併後的 overload 必須保持相容(參數數量、型別順序)。 使用 相同參數順序,或在一個檔案內集中管理 overload 定義。

最佳實踐

  1. 模組化設計:將每個功能的介面放在獨立檔案,最後透過 export/import 讓 TypeScript 自動合併。
  2. 以「單一職責」為原則:只在需要擴充的地方加入屬性,避免一次合併過多不相關的成員。
  3. 使用 declare module:擴充第三方套件時,務必使用 declare module 包裹,保持型別安全。
  4. 加入 JSDoc 註解:合併後的介面可能變得龐大,適當的說明有助於 IDE 自動補完與文件生成。
  5. 測試型別:在 CI 中加入 tsc --noEmittype-check 步驟,確保合併後的型別不會破壞既有程式。

實際應用場景

  1. 擴充第三方庫的 Request/Response
    在 Express、Koa、NestJS 等框架中,常需要在 Request 物件上掛載驗證後的使用者資訊。透過介面合併,我們可以在 專案的型別宣告檔 中一次性完成擴充,所有中間件與控制器都能直接使用 req.userId,不必重複型別斷言。

  2. 插件化系統
    假設開發一個 UI 元件庫,允許外部插件為元件添加自訂屬性。可以在核心庫中定義 interface ComponentProps { id: string; },外部插件再透過 declare module './ComponentProps' 合併 interface ComponentProps { customData?: any; }。這樣插件不需要改動核心程式碼,卻能自然擴充型別。

  3. 大型專案的分層型別
    在微服務或大型單頁應用中,往往會把 Domain ModelDTOAPI Response 分別放在不同檔案。使用介面合併,我們可以在 UserBase.ts 定義基礎欄位,在 UserProfile.tsUserPermission.ts 分別補齊細節,最終在使用端只需要 import { User } from './User' 即可得到完整型別。

  4. 多語系訊息字典
    透過 enum 合併,我們可以把每個語系的訊息列舉拆成多個檔案,最後在編譯時自動合併成一個完整的 MessageKey 列舉,減少手動維護的工作量。


總結

  • Declaration Merging 是 TypeScript 提供的強大特性,讓相同名稱的 interfaceenumnamespace 能自動合併,實現增量式擴充。
  • 透過 介面合併namespace 合併enum 合併,我們可以在不破壞既有程式碼的前提下,為第三方套件或自訂結構加入新屬性或功能。
  • 使用時需留意 型別衝突跨模組合併 以及 全域污染 等常見陷阱,並遵守 模組化單一職責JSDoc 註解 等最佳實踐。
  • 在實務上,宣告合併在 擴充框架 Request插件化系統大型分層型別、以及 多語系訊息管理 等場景中都有顯著的效益。

掌握了宣告合併的概念與技巧,你將能更靈活地構築 TypeScript 型別體系,寫出可維護、可擴充的高品質程式碼。祝你在 TypeScript 的世界裡玩得開心、寫得順手!