本文 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)
任一類別缺少對方的必需成員 另一類別 會產生型別錯誤

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

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;   // 編譯通過,因為結構相容(額外屬性會被忽略)

說明

  • 這裡 Point2DPoint3D 只要具備 xy 兩個屬性,就被視為相容。
  • 方向 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 成員同樣需要 相同宣告來源 才能相容。
  • DerivedADerivedB 皆繼承自同一個 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; // 強制轉型,實務上要小心

說明

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

最佳實踐

  1. 盡量使用介面描述公共合約:介面不會帶來實作負擔,且相容性檢查更直觀。
  2. 將私有屬性限制在同一個模組:如果必須使用 private,確保相關類別在同一檔案或同一套件內。
  3. 利用抽象類別提供共用實作:抽象類別同時具備結構相容與行為共享的優勢。
  4. 在大型專案中加入型別審查規則:例如 noImplicitAnystrictPropertyInitialization,可以提前捕捉相容性問題。
  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 的世界裡玩得開心,寫出更好的程式!