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 或 介面合併 來模擬多重繼承。 |
最佳實踐
- 盡量使用介面描述公共合約:介面不會帶來實作負擔,且相容性檢查更直觀。
- 將私有屬性限制在同一個模組:如果必須使用
private,確保相關類別在同一檔案或同一套件內。 - 利用抽象類別提供共用實作:抽象類別同時具備結構相容與行為共享的優勢。
- 在大型專案中加入型別審查規則:例如
noImplicitAny、strictPropertyInitialization,可以提前捕捉相容性問題。 - 寫單元測試驗證行為:即使型別相容,仍建議為關鍵類別寫測試,確保實作符合預期。
實際應用場景
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 型別系統的核心特徵之一,結構性 的檢查方式讓我們能以「有相同屬性」為基礎,靈活地在不同類別之間指派與重構。掌握以下要點,就能在開發過程中避免常見的型別衝突:
- 結構性 判斷是主要機制,私有/受保護成員則引入名義性限制。
- 私有屬性 必須來自同一個宣告才能相容,否則會報錯。
- 建構子簽名 也會影響相容性,必要時使用抽象類別或建構子型別抽象化。
- 介面 與 抽象類別 是實務上最安全、最易維護的相容性橋樑。
- 在插件、DTO、測試等實務情境中,善用相容性可以大幅減少樣板程式碼,提高開發效率。
透過本文的概念講解與實作範例,你應該已經能在自己的 TypeScript 專案裡,正確判斷與運用類別相容性,寫出既安全又彈性的程式碼。祝你在 TypeScript 的世界裡玩得開心,寫出更好的程式!