TypeScript – 物件與介面(Objects & Interfaces)
主題:多重介面繼承
簡介
在大型前端或 Node.js 專案中,介面(interface) 常被用來描述資料結構、函式簽名或類別的公共行為。隨著專案規模的擴大,單一介面往往無法完整涵蓋所有需求,此時 多重介面繼承(multiple interface inheritance)就成為一個強大且彈性的工具。透過同時繼承多個介面,我們可以把既有的抽象概念組合起來,形成更具表現力的型別,同時保持程式碼的可讀性與可維護性。
本篇文章將以 淺顯易懂 的方式說明多重介面繼承的語法、背後的型別系統原理,並提供 實務範例、常見陷阱與最佳實踐,幫助初學者到中級開發者快速上手並在真實專案中正確運用。
核心概念
1. 介面的基本繼承 – extends
在 TypeScript 中,介面可以使用 extends 關鍵字繼承另一個或多個介面。語法上只要在 extends 後列出欲繼承的介面名稱,並以逗號分隔即可。
interface Person {
name: string;
age: number;
}
interface Employee extends Person {
employeeId: string;
}
重點:
Employee同時擁有Person的name、age,以及自己的employeeId。
2. 多重介面繼承
若要一次繼承兩個以上的介面,只需要在 extends 後列出多個介面名稱:
interface Serializable {
serialize(): string;
}
interface Loggable {
log(message: string): void;
}
/** 同時繼承 Serializable 與 Loggable */
interface DataModel extends Serializable, Loggable {
id: number;
}
DataModel 會同時要求實作 serialize、log 兩個方法,以及 id 屬性。
3. 類別實作多個介面
類別(class)可以 同時實作 多個介面,且每個介面都必須完整實作其所有成員。
class User implements Person, Loggable {
constructor(public name: string, public age: number) {}
log(message: string): void {
console.log(`[${this.name}] ${message}`);
}
}
此時 User 必須符合 Person(name、age)以及 Loggable(log)的規範。
4. 交叉型別(Intersection Types) &
有時我們不想建立新介面,只想在型別層面「合併」多個介面,這時可以使用 交叉型別 (&):
type Auditable = Serializable & Loggable;
const audit: Auditable = {
serialize() { return JSON.stringify(this); },
log(message) { console.log(message); },
// 其他屬性可自行加入
};
交叉型別的行為與多重介面繼承相同,只是它是 型別別名(type alias),不會產生實體的介面宣告。
5. 介面衝突解決
當多個介面定義了同名屬性或方法,但型別不相容時,TypeScript 會在編譯階段報錯。例如:
interface A { value: string; }
interface B { value: number; }
interface C extends A, B {} // ❌ 錯誤:屬性 'value' 同時出現在 A 與 B 中,型別不相容
解決方式通常是 重新命名、抽取共同屬性至基礎介面,或 使用聯合型別 (|) 以允許多種型別。
程式碼範例
以下提供 五個實用範例,說明多重介面繼承的不同應用情境。
範例 1️⃣:組合 UI Props
interface Clickable {
onClick: (e: MouseEvent) => void;
}
interface Focusable {
onFocus: () => void;
}
/** ButtonProps 同時具備 Clickable 與 Focusable 的屬性 */
interface ButtonProps extends Clickable, Focusable {
label: string;
}
/* 使用範例 */
const btn: ButtonProps = {
label: "送出",
onClick(e) { console.log("點擊", e); },
onFocus() { console.log("取得焦點"); }
};
實務意義:在 React、Vue 等框架中,組合多個「行為」介面可以快速產生完整的 component props。
範例 2️⃣:類別實作多個介面(服務層)
interface Cacheable {
getFromCache(key: string): any;
setToCache(key: string, value: any): void;
}
interface RemoteFetchable {
fetchFromServer(endpoint: string): Promise<any>;
}
/** 服務類別同時具備快取與遠端抓取的能力 */
class DataService implements Cacheable, RemoteFetchable {
private cache = new Map<string, any>();
getFromCache(key: string) { return this.cache.get(key); }
setToCache(key: string, value: any) { this.cache.set(key, value); }
async fetchFromServer(endpoint: string) {
const response = await fetch(endpoint);
const data = await response.json();
this.setToCache(endpoint, data);
return data;
}
}
關鍵:透過「介面」劃分職責(Cache、Remote),類別只需關注自己的實作,日後若要替換快取機制,只需要改寫
Cacheable相關類別即可。
範例 3️⃣:交叉型別建構複雜型別
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: string;
}
/** 使用交叉型別組合兩個介面 */
type Entity = Timestamped & Identifiable;
const user: Entity = {
id: "u123",
createdAt: new Date(),
updatedAt: new Date(),
};
說明:
Entity並未宣告為新介面,而是直接以type建立,適合在函式簽名或臨時型別中使用。
範例 4️⃣:混入(Mixin)與多重介面
type Constructor<T = {}> = new (...args: any[]) => T;
/** 可混入的功能介面 */
interface Draggable {
dragStart(): void;
dragEnd(): void;
}
/** Mixin 函式 */
function DraggableMixin<TBase extends Constructor>(Base: TBase) {
return class extends Base implements Draggable {
dragStart() { console.log("開始拖曳"); }
dragEnd() { console.log("結束拖曳"); }
};
}
/** 基礎類別 */
class Widget {
constructor(public name: string) {}
}
/** 產生同時具備 Draggable 的新類別 */
class DraggableWidget extends DraggableMixin(Widget) {}
const w = new DraggableWidget("圖表");
w.dragStart(); // => 開始拖曳
w.dragEnd(); // => 結束拖曳
實務應用:在 UI 框架中,透過 Mixin 可以把「可拖曳」或「可縮放」等行為彈性加入任意類別,而不需要多層繼承。
範例 5️⃣:介面衝突的解決方式
interface Readable {
value: string;
}
interface Writable {
value: number; // 與 Readable 的型別衝突
}
/* 方案 1:抽取共同屬性 */
interface Base {
value: string | number;
}
interface Readable extends Base {}
interface Writable extends Base {}
interface Combined extends Readable, Writable {
// 現在 value 允許 string 或 number
}
/* 方案 2:使用聯合型別 */
type Flexible = Readable | Writable;
const a: Flexible = { value: "hello" }; // OK
const b: Flexible = { value: 42 }; // OK
技巧:當必須同時支援不同型別時,抽取共同基底或使用聯合型別是最安全的做法。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 建議的解決方式 |
|---|---|---|
| 屬性名稱衝突且型別不相容 | 編譯錯誤、難以維護 | 抽取共同基底介面、使用聯合或交叉型別、或重新命名屬性 |
| 過度繼承導致介面龐大 | 失去介面的可讀性,影響 IntelliSense 效能 | 介面保持單一職責(SRP),必要時拆成小介面再組合 |
| 忘記在類別中實作所有介面成員 | 編譯失敗,或在執行時出現 undefined 錯誤 |
使用 implements 時,讓 IDE 自動提示缺漏成員 |
| 交叉型別與聯合型別混用不當 | 型別推斷變得模糊,導致意外的 any |
明確區分「必須同時具備」(&) 與「任一」(` |
| 循環依賴的介面 | 編譯時無法解析,可能產生 Maximum call stack size exceeded |
重新設計介面結構,或使用 type 別名搭配前置聲明 |
最佳實踐
- 介面保持小而專:每個介面只描述一組相關的行為或屬性,組合時再使用
extends或交叉型別。 - 優先使用交叉型別:當只需要臨時合併型別時,
type A = B & C更簡潔。 - 命名規則:介面名稱以 I 開頭(如
IUser)或使用描述性名詞(如Serializable),保持一致性。 - 避免循環依賴:若兩個介面互相引用,考慮抽出共同部分或改用
type別名。 - 利用
readonly與?:在多重繼承的情況下,使用readonly防止意外改寫,使用可選屬性 (?) 提供彈性。
實際應用場景
| 場景 | 為何需要多重介面繼承 | 範例 |
|---|---|---|
| API 回應模型 | 一個回應可能同時具備分頁資訊、錯誤代碼與實體資料 | interface Paginated extends PageInfo, ErrorInfo { data: T[]; } |
| React Component Props | 按鈕需要同時支援點擊、鍵盤、懸停等行為 | interface ButtonProps extends Clickable, Keyboardable, Hoverable { label: string; } |
| Domain Entity | 企業系統中,實體往往同時是可追蹤 (Timestamped) 且可辨識 (Identifiable) |
type Entity = Timestamped & Identifiable; |
| 微服務間的契約 | 服務間傳遞的訊息可能同時包含驗證資訊與加密資訊 | interface Message extends Verifiable, Encryptable { payload: any; } |
| 混入式功能擴充 | 為既有類別動態加入「可拖曳」或「可縮放」等 UI 行為 | class DraggableWidget extends DraggableMixin(Widget) {} |
透過多重介面繼承,我們能夠 在型別層面組合職責,同時保持程式碼的 可讀性 與 可測試性,這在大型團隊開發與長期維護中尤其重要。
總結
- 多重介面繼承是 TypeScript 型別系統裡 組合 與 抽象 的核心工具。
- 使用
extends可以一次繼承多個介面,類別則可透過implements同時實作多個介面。 - 交叉型別 (
&) 提供了 不建立實體介面 的快速合併方式,適合臨時或函式型別。 - 必須留意 屬性衝突、介面過大 與 循環依賴 等常見陷阱,並遵循 單一職責、明確命名、適度使用
readonly的最佳實踐。 - 在 UI 組件、API 合約、服務層與混入式功能等真實場景中,多重介面繼承能顯著提升程式碼的彈性與可維護性。
掌握了這些概念後,你就可以在 TypeScript 專案裡 以「組合」取代「繼承」 的方式,寫出更乾淨、更安全的程式碼。祝你在日常開發中玩得開心,寫出高品質的 TypeScript 程式!