本文 AI 產出,尚未審核

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 賦予一個僅有 nameAnimal,就會破壞 dogBox 原本的結構,導致執行時錯誤。


常見陷阱與最佳實踐

陷阱 說明 解決方案
函式參數的雙向相容 TypeScript 預設允許 雙向(bivariant)函式參數,以提升開發便利性,卻可能隱藏錯誤。 tsconfig.json 中啟用 strictFunctionTypes: true,讓參數變為 逆變
陣列協變導致意外寫入 若使用普通 Array<T>(可變),會被視為不變,指派會失敗;但若誤用 readonly,可能讓協變過度寬鬆。 明確使用 ReadonlyArray<T>readonly T[] 表示只讀,避免意外寫入。
泛型類別的變異 泛型類別的型別參數預設是 不變,若想要協變或逆變,需要手動調整介面結構(如把屬性改為只讀或把方法參數抽象化)。 使用 型別映射(type mapping)或 條件型別 來建立協變/逆變的變體。
任意 any 逃脫檢查 any 當作參數或返回值會繞過變異檢查,降低型別安全。 儘量避免 any,改用 unknown 或具體型別。

最佳實踐清單

  1. 啟用嚴格模式strict: truestrictFunctionTypes: true
  2. 盡量使用只讀結構readonlyReadonlyArray),讓協變安全可用。
  3. 函式參數使用逆變:若需要接受更廣泛的型別,寫成 (arg: SuperType) => void
  4. 為泛型容器設計明確的變異策略:例如 interface Producer<out T>(模擬協變),interface Consumer<in T>(模擬逆變)。
  5. 寫測試:特別是涉及函式傳遞或事件系統的部分,確保型別變異不會在執行時產生錯誤。

實際應用場景

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)角色,使接受更廣泛型別的函式可以安全地代替接受較窄型別的函式。
  • 不變發生在同時支援讀寫的容器,必須保持型別完全相同才能相容。

在實務開發中,正確運用這三種變異,可讓我們:

  1. 設計更彈性的 API(例如事件回呼、Observable、DI 容器)。
  2. 避免因型別錯配而產生的執行時錯誤
  3. 在嚴格模式下仍保持開發效率,因為 TypeScript 會自動幫我們檢查協變與逆變的正確性。

最後,養成啟用嚴格模式、使用只讀結構、以及在函式參數上遵守逆變原則的好習慣,將讓你的 TypeScript 專案更健壯、更易維護。祝你在型別相容性的世界裡玩得開心,寫出安全、可預測的程式碼!