本文 AI 產出,尚未審核

TypeScript 課程 – 型別相容性與型別系統(Type Compatibility)

主題:類別相容性


簡介

在日常的前端與 Node.js 開發裡,我們常會使用 類別(class) 來封裝資料與行為。雖然 TypeScript 的類別語法看起來與 ES6 完全相同,但在型別層面上,TypeScript 仍會對「兩個類別是否可以相互指派」進行**相容性(compatibility)**的檢查。

了解類別相容性的原理,能讓你在重構程式碼、撰寫函式庫或是使用第三方套件時,避免因型別不匹配而產生的編譯錯誤或執行時 bug。這篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步掌握 類別相容性 在 TypeScript 中的行為與應用。


核心概念

1. 結構性型別系統(Structural Type System)

TypeScript 採用 結構性(structural)而非 名義性(nominal)的型別系統。這表示兩個類別只要在 形狀(屬性與方法)上相同,就被視為相容,即使它們的名稱、繼承關係或 instanceof 判斷結果不同。

⚠️ 重點:相容性是根據「成員」而非「宣告的類別」來決定的。

2. 基本相容規則

類別 A 類別 B 是否相容? 說明
A 有所有 B 的公有屬性/方法 B 是 A 可以指派給 B(A → B)
B 有所有 A 的公有屬性/方法 A 是 B 可以指派給 A(B → A)
任一類別缺少對方的必需成員 另一類別 否 會產生型別錯誤

註:私有屬性(private、protected)會在相容性檢查中加入「名義」概念,只有擁有相同宣告來源的類別才會相容。

3. 私有與受保護成員的影響

  • 私有屬性:只要兩個類別宣告了同名的私有屬性,卻 不是同一個宣告,它們就不會相容。
  • 受保護屬性:同樣需要「同一個宣告」才能相容,否則會被視為不相容。

這是 TypeScript 為了保護封裝性所做的限制,讓你不會不小心把外部物件當成內部實例使用。

4. 建構子相容性

類別的 建構子簽名 也是相容性判斷的一部份。若兩個類別的建構子參數型別不兼容,則在「使用 new」的情況下會產生錯誤。


程式碼範例

以下範例均使用 TypeScript(.ts),在 Markdown 中以 typescript 標記。

範例 1:最簡單的結構性相容

class Point2D {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

class Point3D {
  x: number;
  y: number;
  z: number;
  constructor(x: number, y: number, z: number) {
    this.x = x;
    this.y = y;
    this.z = z;
  }
}

// Point2D 只要有 x、y,就可以指派給 Point3D 的子集合
let p2: Point2D = new Point2D(1, 2);
let p3: Point3D = new Point3D(1, 2, 3);

// ✅ 方向 A → B:把 Point2D 當成 Point3D 使用(缺少 z,但不會檢查額外屬性)
let p3From2: Point3D = p2;   // 編譯通過

// ❌ 方向 B → A:把 Point3D 當成 Point2D 使用,缺少 z 不影響,但若檢查額外屬性會失敗
let p2From3: Point2D = p3;   // 編譯通過,因為結構相容(額外屬性會被忽略)

說明:

  • 這裡 Point2D 與 Point3D 只要具備 x、y 兩個屬性,就被視為相容。
  • 方向 Point2D → Point3D 仍然編譯通過,因為 結構性 只檢查目標型別所需的成員是否存在。

範例 2:私有屬性破壞相容性

class Animal {
  private id: number;   // 私有屬性
  name: string;
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

class Dog extends Animal {
  breed: string;
  constructor(id: number, name: string, breed: string) {
    super(id, name);
    this.breed = breed;
  }
}

class Cat {
  private id: number;   // 同樣名稱的私有屬性,但不是同一個宣告
  name: string;
  constructor(id: number, name: string) {
    this.id = id;
    this.name = name;
  }
}

let dog: Dog = new Dog(1, "Buddy", "Labrador");
let cat: Cat = new Cat(2, "Mimi");

// ❌ 下面會錯誤:Dog 與 Cat 的私有屬性來源不同
// let animal: Animal = cat;   // Error: Types have separate declarations of a private property 'id'.
// let animal2: Animal = dog;  // OK,因為 Dog 繼承自 Animal

說明:

  • Dog 能指派給 Animal,因為它是 Animal 的子類別,且私有屬性 id 來源相同。
  • Cat 雖然結構上和 Animal 完全相同,但因為 id 是 不同宣告 的私有屬性,兩者不相容。

範例 3:受保護屬性與繼承鏈

class Base {
  protected timestamp: Date = new Date();
  public log() {
    console.log(this.timestamp);
  }
}

class DerivedA extends Base {
  foo() {
    console.log("A");
  }
}

class DerivedB extends Base {
  bar() {
    console.log("B");
  }
}

let a: DerivedA = new DerivedA();
let b: DerivedB = new DerivedB();

// ✅ 受保護屬性不影響相容性,只要同一個基底類別即可
let baseFromA: Base = a;   // OK
let baseFromB: Base = b;   // OK

// ❌ 直接把 DerivedA 指派給 DerivedB 會錯誤,因為兩者沒有共同的結構
// let bFromA: DerivedB = a;   // Error

說明:

  • protected 成員同樣需要 相同宣告來源 才能相容。
  • DerivedA 與 DerivedB 皆繼承自同一個 Base,因此它們都可以指派給 Base。

範例 4:建構子相容性

class ServiceA {
  constructor(public url: string) {}
  request() {
    console.log(`Request to ${this.url}`);
  }
}

class ServiceB {
  constructor(public endpoint: string, public timeout: number = 5000) {}
  request() {
    console.log(`Request to ${this.endpoint} with timeout ${this.timeout}`);
  }
}

// 只要建構子參數可以接受目標型別的參數,就視為相容
let svcA: ServiceA = new ServiceA("https://api.example.com");

// ❌ ServiceB 的建構子需要兩個參數,不能直接指派給 ServiceA
// let svcB: ServiceA = new ServiceB("https://api.example.com"); // Error

// 但可以利用類別表達式(class expression)或抽象類別來抽象建構子
type ServiceCtor = new (url: string) => ServiceA;
const CtorB: ServiceCtor = ServiceB as any; // 強制轉型,實務上要小心

說明:

  • ServiceA 與 ServiceB 的建構子簽名不同,直接指派會失敗。
  • 若需要把不同建構子抽象成同一介面,可使用 建構子型別(constructor type)或 抽象類別 來統一。

範例 5:介面與類別的混用

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

// 任意有 log 方法的類別皆符合 ILogger
class ConsoleLogger {
  log(message: string) {
    console.log("[Console]", message);
  }
}

class FileLogger {
  private filePath: string = "/tmp/log.txt";
  log(message: string) {
    // 實作寫入檔案的邏輯
  }
}

// ✅ 兩個類別都可以指派給 ILogger
let logger1: ILogger = new ConsoleLogger();
let logger2: ILogger = new FileLogger();

說明:

  • 介面本身不會產生「實例」;只要類別具備介面定義的結構,就自動相容。
  • 這是 TypeScript 常見的 duck typing(鴨子類型)寫法。

常見陷阱與最佳實踐

陷阱 說明 解決方案
私有屬性導致相容性失效 在不同檔案或套件中自行宣告 private id,看似相同卻不相容。 使用 protected 或 public,或將共用私有屬性抽取到 抽象基底類別 中。
過度依賴結構相容 只因結構相同就直接指派,可能在執行時缺少預期的行為(例如缺少方法實作)。 為重要的類別建立 介面,或使用 抽象類別 明確規範行為。
建構子相容性忽略 把需要額外參數的類別當成簡單型別使用,會在 new 時產生錯誤。 使用 工廠函式 或 建構子型別 抽象化建構子。
受保護屬性誤用 在子類別外部直接存取 protected 成員,編譯會失敗。 只在子類別或子類別的子類別內使用,或提供 getter / setter。
多重繼承的錯誤假設 TypeScript 不支援多重繼承,若期待類別同時繼承兩個基底會失敗。 使用 mixins 或 介面合併 來模擬多重繼承。

最佳實踐

  1. 盡量使用介面描述公共合約:介面不會帶來實作負擔,且相容性檢查更直觀。
  2. 將私有屬性限制在同一個模組:如果必須使用 private,確保相關類別在同一檔案或同一套件內。
  3. 利用抽象類別提供共用實作:抽象類別同時具備結構相容與行為共享的優勢。
  4. 在大型專案中加入型別審查規則:例如 noImplicitAny、strictPropertyInitialization,可以提前捕捉相容性問題。
  5. 寫單元測試驗證行為:即使型別相容,仍建議為關鍵類別寫測試,確保實作符合預期。

實際應用場景

1. 插件系統(Plugin Architecture)

在一個需要外部插件的應用程式中,核心會定義一個 基底類別 或 介面,插件則繼承或實作它。只要插件類別符合基底的結構,即可在執行時動態載入。

// core.ts
export abstract class Plugin {
  abstract init(): void;
  abstract destroy(): void;
}

// pluginA.ts
import { Plugin } from "./core";

export class LoggerPlugin extends Plugin {
  init() { console.log("Logger init"); }
  destroy() { console.log("Logger destroy"); }
}

// main.ts
import { Plugin } from "./core";
import { LoggerPlugin } from "./pluginA";

function load(p: Plugin) {
  p.init();
  // ...
}
load(new LoggerPlugin());   // 相容,因為結構符合抽象類別

2. 資料傳輸物件(DTO)與映射

在前後端分離的專案裡,前端會收到 JSON,然後轉成類別實例。只要類別的屬性與 JSON 結構相容,即可直接指派,省去繁雜的手動映射。

interface UserDTO {
  id: number;
  name: string;
  email: string;
}

// 直接把 JSON 轉成類別實例(結構相容)
class User {
  id!: number;
  name!: string;
  email!: string;
  // 其他方法...
}

const json = fetch("/api/user/1").then(r => r.json()) as Promise<UserDTO>;
json.then(data => {
  const user: User = data as any;   // 透過相容性直接轉型
  console.log(user.name);
});

3. 測試替身(Test Stubs / Mocks)

在單元測試中,我們常用「假類別」來模擬真實的服務。只要假類別與真實類別結構相同,即可替換使用,無需改變測試程式碼。

class HttpService {
  get(url: string): Promise<string> {
    // 真實的 HTTP 請求
    return fetch(url).then(r => r.text());
  }
}

// 測試時的 mock
class MockHttpService {
  get(url: string): Promise<string> {
    return Promise.resolve(`Mock response from ${url}`);
  }
}

// 測試函式接受抽象型別
function fetchData(service: HttpService) {
  return service.get("/api/data");
}

// 在測試環境注入 Mock
fetchData(new MockHttpService() as any).then(console.log);

總結

類別相容性是 TypeScript 型別系統的核心特徵之一,結構性 的檢查方式讓我們能以「有相同屬性」為基礎,靈活地在不同類別之間指派與重構。掌握以下要點,就能在開發過程中避免常見的型別衝突:

  1. 結構性 判斷是主要機制,私有/受保護成員則引入名義性限制。
  2. 私有屬性 必須來自同一個宣告才能相容,否則會報錯。
  3. 建構子簽名 也會影響相容性,必要時使用抽象類別或建構子型別抽象化。
  4. 介面 與 抽象類別 是實務上最安全、最易維護的相容性橋樑。
  5. 在插件、DTO、測試等實務情境中,善用相容性可以大幅減少樣板程式碼,提高開發效率。

透過本文的概念講解與實作範例,你應該已經能在自己的 TypeScript 專案裡,正確判斷與運用類別相容性,寫出既安全又彈性的程式碼。祝你在 TypeScript 的世界裡玩得開心,寫出更好的程式!