TypeScript 型別相容性與型別系統:協變(Covariance)與逆變(Contravariance)
簡介
在 TypeScript 中,型別相容性(type compatibility)是編譯器決定兩個型別是否可以互相指派的核心機制。
隨著程式碼規模變大,我們常會遇到「函式參數」或「泛型容器」的型別關係問題,此時就會牽涉到 協變(Covariance) 與 逆變(Contravariance) 的概念。
了解這兩種變異(variance)規則,能幫助我們:
- 寫出更安全且可預測的 API
- 避免因型別不匹配而產生的執行時錯誤
- 善用 TypeScript 的型別推斷與泛型
本文將以淺顯易懂的方式說明協變與逆變的原理、常見陷阱與最佳實踐,並提供實務範例供讀者直接套用於日常開發。
核心概念
1. 什麼是變異(Variance)?
在型別系統裡,「變異」描述的是子型別與父型別之間的相容關係。
最常見的三種變異:
| 變異類型 | 定義 | 範例 |
|---|---|---|
| 協變 (Covariant) | 子型別可以替代父型別(從左往右) | Array<Dog> 可被視為 Array<Animal>(只讀情況) |
| 逆變 (Contravariant) | 父型別可以替代子型別(從右往左) | (animal: Animal) => void 可接受 (dog: Dog) => void |
| 不變 (Invariant) | 必須完全相同才能相容 | MutableArray<Dog> 與 MutableArray<Animal> 不相容 |
注意:在 JavaScript/TypeScript 裡,函式參數的型別是 逆變,而返回值則是 協變。
2. 協變(Covariance)
協變表示「如果 A 是 B 的子型別,則 Container<A> 也是 Container<B> 的子型別」。
最典型的例子是只讀陣列或只讀屬性。
範例 1:只讀陣列的協變
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const dogs: ReadonlyArray<Dog> = [{ name: 'Buddy', bark: () => console.log('Woof!') }];
// 只讀陣列是協變的,允許把 ReadonlyArray<Dog> 指派給 ReadonlyArray<Animal>
const animals: ReadonlyArray<Animal> = dogs;
// 下面的寫入操作會編譯錯誤,因為陣列是只讀的
// animals.push({ name: 'Milo' }); // Error
要點:只要容器不允許寫入(即「只讀」),就可以安全地使用協變,因為不會破壞原始子型別的結構。
範例 2:介面屬性的協變
interface Box<T> {
readonly value: T; // 只讀屬性 => 協變
}
type AnimalBox = Box<Animal>;
type DogBox = Box<Dog>;
let dogBox: DogBox = { value: { name: 'Rex', bark: () => {} } };
let animalBox: AnimalBox = dogBox; // OK,DogBox 可視為 AnimalBox
3. 逆變(Contravariance)
逆變則是「如果 A 是 B 的子型別,則 Consumer<A> 是 Consumer<B> 的父型別」。
最常見的情境是函式參數。
範例 3:函式參數的逆變
type Handler<T> = (arg: T) => void;
const animalHandler: Handler<Animal> = (a) => console.log(a.name);
const dogHandler: Handler<Dog> = (d) => d.bark();
// 逆變允許把接受更廣泛型別的函式指派給接受較窄型別的變數
const handler: Handler<Dog> = animalHandler; // OK
// 反過來則不行(會導致執行時錯誤)
// const bad: Handler<Animal> = dogHandler; // Error
在上述例子中,animalHandler 能接受任何 Animal,自然也能接受 Dog(因為 Dog 繼承自 Animal),所以它可以安全地被視為 Handler<Dog>。
範例 4:事件系統的逆變
interface Event<T> {
(payload: T): void;
}
interface MouseEvent { x: number; y: number; }
interface ClickEvent extends MouseEvent { button: number; }
let onMouse: Event<MouseEvent> = e => console.log(`mouse at (${e.x},${e.y})`);
let onClick: Event<ClickEvent> = e => console.log(`button ${e.button}`);
// 逆變:接受較寬的 MouseEvent 可以當作 ClickEvent 使用
let clickListener: Event<ClickEvent> = onMouse; // OK
// 但不能把只能處理 ClickEvent 的函式指派給 MouseEvent
// let mouseListener: Event<MouseEvent> = onClick; // Error
4. 不變(Invariant)— 為什麼有時候會失敗?
當容器同時支援寫入與讀取時,型別相容性會變成 不變(invariant),因為協變與逆變同時成立會產生安全性問題。
範例 5:可變陣列的不變
interface MutableBox<T> {
value: T; // 可寫入 => 不變
}
type AnimalBox = MutableBox<Animal>;
type DogBox = MutableBox<Dog>;
let dogBox: DogBox = { value: { name: 'Luna', bark: () => {} } };
// 以下指派會錯誤,因為若允許,就可能把非 Dog 放進 DogBox
// let animalBox: AnimalBox = dogBox; // Error
如果允許上述指派,之後對 animalBox.value 賦予一個僅有 name 的 Animal,就會破壞 dogBox 原本的結構,導致執行時錯誤。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 函式參數的雙向相容 | TypeScript 預設允許 雙向(bivariant)函式參數,以提升開發便利性,卻可能隱藏錯誤。 | 在 tsconfig.json 中啟用 strictFunctionTypes: true,讓參數變為 逆變。 |
| 陣列協變導致意外寫入 | 若使用普通 Array<T>(可變),會被視為不變,指派會失敗;但若誤用 readonly,可能讓協變過度寬鬆。 |
明確使用 ReadonlyArray<T> 或 readonly T[] 表示只讀,避免意外寫入。 |
| 泛型類別的變異 | 泛型類別的型別參數預設是 不變,若想要協變或逆變,需要手動調整介面結構(如把屬性改為只讀或把方法參數抽象化)。 | 使用 型別映射(type mapping)或 條件型別 來建立協變/逆變的變體。 |
任意 any 逃脫檢查 |
把 any 當作參數或返回值會繞過變異檢查,降低型別安全。 |
儘量避免 any,改用 unknown 或具體型別。 |
最佳實踐清單
- 啟用嚴格模式:
strict: true、strictFunctionTypes: true。 - 盡量使用只讀結構(
readonly、ReadonlyArray),讓協變安全可用。 - 函式參數使用逆變:若需要接受更廣泛的型別,寫成
(arg: SuperType) => void。 - 為泛型容器設計明確的變異策略:例如
interface Producer<out T>(模擬協變),interface Consumer<in T>(模擬逆變)。 - 寫測試:特別是涉及函式傳遞或事件系統的部分,確保型別變異不會在執行時產生錯誤。
實際應用場景
1. UI 元件庫的事件回呼
在設計像 React、Vue 這樣的 UI 框架時,事件回呼通常會接受比實際觸發更廣的資料。例如:
type MouseEventHandler = (e: MouseEvent) => void;
type ClickEventHandler = (e: ClickEvent) => void;
// 允許使用者提供只處理 MouseEvent 的回呼,作為 ClickEvent 的處理器
const onClick: ClickEventHandler = (e) => console.log(e.button);
const genericHandler: MouseEventHandler = (e) => console.log(e.x, e.y);
// 逆變允許
const handler: ClickEventHandler = genericHandler; // OK
這樣的設計讓庫的使用者可以在不需要關心所有屬性的情況下,直接使用較簡單的回呼。
2. 資料流(Observable)與觀察者模式
RxJS 等庫的 Observable<T> 本質上是 協變:Observable<Dog> 可以被當作 Observable<Animal> 使用,因為觀察者只會 讀取值。
import { Observable } from 'rxjs';
interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }
const dog$ = new Observable<Dog>(observer => {
observer.next({ name: 'Max', bark: () => console.log('Woof') });
});
const animal$: Observable<Animal> = dog$; // 協變,安全
如果我們要實作自己的 Subject<T>(同時可 寫入 與 讀取),則必須將其設計為 不變,或使用 readonly 只讀的發佈端。
3. 依賴注入(DI)容器
DI 容器常常需要把 抽象類別或介面的實作注入到需要更具體型別的地方。這裡的「提供者」是 逆變:
interface Logger { log(msg: string): void; }
class ConsoleLogger implements Logger { log(msg) { console.log(msg); } }
type LoggerFactory<T extends Logger> = () => T;
const consoleFactory: LoggerFactory<ConsoleLogger> = () => new ConsoleLogger();
const genericFactory: LoggerFactory<Logger> = consoleFactory; // 逆變 OK
這樣的設計允許在高層模組只依賴 Logger,而在低層提供更具體的實作。
總結
- 協變讓子型別容器可以安全地被當作父型別容器使用,前提是容器是只讀的(如
readonly屬性、ReadonlyArray)。 - 逆變則適用於函式參數或消費者(consumer)角色,使接受更廣泛型別的函式可以安全地代替接受較窄型別的函式。
- 不變發生在同時支援讀寫的容器,必須保持型別完全相同才能相容。
在實務開發中,正確運用這三種變異,可讓我們:
- 設計更彈性的 API(例如事件回呼、Observable、DI 容器)。
- 避免因型別錯配而產生的執行時錯誤。
- 在嚴格模式下仍保持開發效率,因為 TypeScript 會自動幫我們檢查協變與逆變的正確性。
最後,養成啟用嚴格模式、使用只讀結構、以及在函式參數上遵守逆變原則的好習慣,將讓你的 TypeScript 專案更健壯、更易維護。祝你在型別相容性的世界裡玩得開心,寫出安全、可預測的程式碼!