TypeScript 物件與介面:介面繼承(extends)
簡介
在大型前端專案或 Node.js 後端服務中,型別安全是維持程式碼可讀性與維護性的關鍵。TypeScript 透過 interface 讓開發者可以為物件、函式甚至類別定義結構,而 介面繼承(extends)則提供了 重用與擴充 型別的能力。
透過介面繼承,我們可以:
- 抽象共同屬性,避免重複宣告。
- 建立層級式的型別,讓程式碼更具語意。
- 在不同模組間共享合約,提升團隊協作效率。
本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹 TypeScript 介面繼承的使用方式,幫助初學者快速上手,也讓中級開發者在實務上得到更多靈感。
核心概念
1. 基本語法
interface 可以像類別一樣使用 extends 來繼承另一個介面。被繼承的介面稱為 基礎介面(base interface),而繼承它的介面稱為 衍生介面(derived interface)。
interface Person {
name: string;
age: number;
}
/* PersonInfo 繼承 Person,並額外加入 address 屬性 */
interface PersonInfo extends Person {
address: string;
}
此時 PersonInfo 同時擁有 name、age 與 address 三個屬性,使用上等同於一次宣告三個欄位。
2. 多重繼承
TypeScript 允許介面同時繼承多個基礎介面,形成 交叉型別(intersection type)的效果。
interface Identifiable {
id: string;
}
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
/* 同時繼承 Identifiable 與 Timestamped */
interface BlogPost extends Identifiable, Timestamped {
title: string;
content: string;
}
BlogPost 會擁有 id、createdAt、updatedAt、title、content 五個屬性。
3. 介面繼承類別
介面不僅能繼承其他介面,也可以 繼承類別的公有與受保護成員。這在需要把類別的結構抽象為型別時非常有用。
class Animal {
protected species: string;
constructor(species: string) {
this.species = species;
}
public move(distance: number): void {
console.log(`${this.species} moved ${distance}m`);
}
}
/* DogInterface 繼承 Animal 的公有與受保護成員 */
interface DogInterface extends Animal {
bark(): void;
}
/* 實作 DogInterface 的類別 */
class Dog extends Animal implements DogInterface {
bark() {
console.log('Woof!');
}
}
DogInterface 包含了 species(受保護)與 move(公有)兩個成員,實作類別時直接繼承 Animal 即可滿足介面的要求。
4. 可選屬性與只讀屬性在繼承中的行為
- 可選屬性(
?)在衍生介面中仍保持可選,除非在衍生介面重新宣告為必填。 - 只讀屬性(
readonly)會沿襲至衍生介面,且不可在衍生介面中改為可寫。
interface Base {
readonly id: number;
name?: string;
}
/* 重新宣告 name 為必填,id 仍為 readonly */
interface Derived extends Base {
name: string; // 從可選變為必填
}
5. 介面與型別別名(type)的差異
雖然 type 也支援交叉 (&) 與擴展 (extends) 的概念,但 介面在宣告合併(declaration merging)上更具彈性。如果需要在多個檔案中擴充同一個型別,使用 interface 會比較直觀。
// fileA.ts
export interface Config {
host: string;
}
// fileB.ts
import { Config } from './fileA';
export interface Config {
port: number; // 自動合併成 { host: string; port: number; }
}
程式碼範例
範例 1:基本繼承與物件驗證
interface User {
username: string;
email: string;
}
/* 讓 Admin 繼承 User,並加入權限屬性 */
interface Admin extends User {
/** 管理員等級,1 為最低,5 為最高 */
level: 1 | 2 | 3 | 4 | 5;
}
/* 正確使用範例 */
const superAdmin: Admin = {
username: 'alice',
email: 'alice@example.com',
level: 5,
};
/* 錯誤示範:缺少 level 會在編譯時報錯 */
const invalidAdmin: Admin = {
username: 'bob',
email: 'bob@example.com',
// ❌ Property 'level' is missing
};
範例 2:多重繼承與交叉型別
interface HasId {
id: string;
}
interface HasMeta {
createdBy: string;
updatedBy?: string; // 可選
}
/* 結合兩個介面,形成完整的資料模型 */
interface Article extends HasId, HasMeta {
title: string;
content: string;
}
/* 實例 */
const article: Article = {
id: 'a1b2c3',
createdBy: 'admin',
title: 'TypeScript 介面繼承教學',
content: '...',
// updatedBy 可以省略
};
範例 3:介面繼承類別與抽象類別的結合
abstract class Shape {
constructor(public color: string) {}
abstract area(): number;
}
/* 讓 CircleInterface 繼承 Shape,保留抽象方法 */
interface CircleInterface extends Shape {
radius: number;
}
/* 實作 Circle,同時滿足介面與抽象類別的要求 */
class Circle extends Shape implements CircleInterface {
constructor(public color: string, public radius: number) {
super(color);
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
/* 使用 */
const c = new Circle('red', 10);
console.log(`Area: ${c.area()}, Color: ${c.color}`);
範例 4:可選屬性變必填與只讀屬性保護
interface BaseConfig {
readonly version: number;
debug?: boolean;
}
/* 在衍生介面中把 debug 變為必填,version 仍為 readonly */
interface FullConfig extends BaseConfig {
debug: boolean; // 必填
}
/* 正確使用 */
const cfg: FullConfig = {
version: 1,
debug: true,
};
/* 嘗試修改 readonly 屬性會錯誤 */
cfg.version = 2; // ❌ Cannot assign to 'version' because it is a read-only property.
範例 5:介面合併(Declaration Merging)實務應用
// lib.d.ts
export interface HttpResponse {
status: number;
data: any;
}
// app.ts
import { HttpResponse } from './lib';
/* 透過宣告合併為回傳加入 header */
export interface HttpResponse {
headers: Record<string, string>;
}
/* 現在 HttpResponse 同時擁有 status、data、headers */
const resp: HttpResponse = {
status: 200,
data: { message: 'OK' },
headers: { 'content-type': 'application/json' },
};
常見陷阱與最佳實踐
| 常見陷阱 | 說明 | 解決方式 |
|---|---|---|
忘記 extends 時的屬性遺失 |
直接在衍生介面中重新宣告相同屬性,可能會造成屬性不一致。 | 使用 介面繼承 而非重複宣告,讓 TypeScript 自動檢查一致性。 |
| 多重繼承衝突 | 兩個基礎介面有同名屬性且型別不相容,會產生錯誤。 | 盡量避免在不同介面中定義相同名稱的屬性,或使用 交叉型別 (&) 重新組合。 |
| 只讀屬性被意外改寫 | 在衍生介面或實作類別中忘記 readonly,編譯器不會警告。 |
始終保留 基礎介面的 readonly 修飾,必要時在衍生介面中再次宣告 readonly。 |
| 介面繼承類別時的建構子 | 介面無法繼承類別的建構子,可能導致實作類別忘記傳遞參數。 | 在介面中只繼承 實例屬性與方法,建構子需求仍需在類別中自行處理。 |
| 過度繼承造成層級過深 | 多層繼承會讓型別圖變得難以追蹤。 | 適度使用 extends,必要時改用 型別別名的交叉 (type A = B & C) 來保持扁平結構。 |
最佳實踐
- 用介面描述公開合約:API 回傳、函式參數等應盡量使用
interface,讓其他模組只依賴合約而非實作。 - 保持單一職責:每個介面只描述一組相關屬性,透過
extends組合成更完整的型別。 - 善用宣告合併:當第三方套件的型別需要擴充時,使用介面合併而非修改原始檔案。
- 配合
readonly與?:在基礎介面中先設為只讀與可選,讓衍生介面根據需求提升約束。 - 測試型別:利用
// @ts-expect-error或as const在單元測試中驗證介面的相容性,避免未預期的型別變更。
實際應用場景
| 場景 | 為何使用介面繼承 | 範例 |
|---|---|---|
| REST API 回傳模型 | 不同端點共用 id、createdAt,但各自有專屬欄位。 |
interface BaseEntity { id: string; createdAt: Date; }interface User extends BaseEntity { username: string; } |
| Redux 狀態樹 | 基礎狀態 (loading, error) 於所有 slice 中共用。 |
interface SliceState { loading: boolean; error?: string; }interface TodoState extends SliceState { items: Todo[]; } |
| 第三方套件擴充 | 想在 express.Request 加入自訂屬性。 |
declare module 'express-serve-static-core' { interface Request { user?: User; } } |
| UI 元件屬性 | 基本屬性 (className, style) 被多個元件共用。 |
interface BaseProps { className?: string; style?: React.CSSProperties; }interface ButtonProps extends BaseProps { onClick: () => void; } |
| Domain Model 繼承 | 企業領域模型中,Employee 繼承 Person,再加上 department。 |
interface Person { name: string; age: number; }interface Employee extends Person { department: string; } |
總結
- 介面繼承 (
extends) 是 TypeScript 中強大的型別重用機制,能讓我們在保持 型別安全 的同時,寫出更具可讀性與維護性的程式碼。 - 透過 單一繼承、多人繼承、介面合併,我們可以彈性組合屬性、方法與只讀/可選限制,滿足各種實務需求。
- 注意 屬性衝突、只讀保護與過度層級 等常見陷阱,並遵循 單一職責、介面作為合約 的最佳實踐,能讓專案在長期演進中保持穩定。
掌握了介面繼承後,你就能在大型 TypeScript 專案中,像搭積木一樣組合型別,快速建構出清晰、可擴充的程式架構。祝你在 TypeScript 的世界裡寫出更安全、更好維護的程式碼!