TypeScript 課程 – 型別相容性與型別系統
單元:函式相容性(Function Compatibility)
簡介
在 TypeScript 中,函式相容性是型別系統的核心之一。它決定了兩個函式(或方法)在型別檢查時是否可以互相替換。若相容性判斷失誤,程式在執行時就可能出現不可預期的錯誤;相反地,正確利用相容性則能讓我們在保持型別安全的前提下,寫出更具彈性與可重用性的程式碼。
本篇文章將以 淺顯易懂 的方式,從基本概念出發,說明函式相容性的規則、常見陷阱與最佳實踐,並提供多個實務範例,協助 初學者到中級開發者 能在日常開發中安全、有效地運用這項特性。
核心概念
1. 參數的雙向協變(Bivariant)與逆變(Contravariant)
在 TypeScript 中,函式參數的相容性遵循 逆變(contravariance)的原則:來源型別(source)必須能接受 目標型別(target)所傳入的所有參數。簡單來說,接受較寬鬆參數的函式 可以被 接受較嚴格參數的函式 取代。
type Handler = (event: MouseEvent) => void;
function onClick(e: MouseEvent) {
console.log(e.clientX);
}
// 合法:onClick 能處理 MouseEvent,符合 Handler 的要求
const handler: Handler = onClick;
若把 Handler 改成接受 Event(更一般的型別),則下列寫法會 失敗,因為 onClick 只能處理 MouseEvent,無法保證能處理所有 Event。
type GeneralHandler = (event: Event) => void;
// ❌ TypeScript 錯誤:'onClick' 不能分配給 'GeneralHandler'
const general: GeneralHandler = onClick;
重點:參數型別是逆變的,較「寬」的參數(
Event)不可以指派給較「窄」的參數(MouseEvent)的函式。
2. 回傳值的協變(Covariant)
回傳值的相容性則是 協變(covariant)的:來源型別的回傳值必須是 目標型別的子型別,或更具體的型別。
type Creator = () => Animal;
class Animal {
name = "animal";
}
class Dog extends Animal {
bark() { console.log("Woof!"); }
}
// 合法:Dog 是 Animal 的子類別
const createDog: Creator = () => new Dog;
若回傳 Animal 但期待 Dog,則會產生錯誤:
type DogCreator = () => Dog;
// ❌ TypeScript 錯誤:'() => Animal' 不能分配給 '() => Dog'
const createAnimal: DogCreator = () => new Animal;
重點:回傳值是協變的,較「具體」的回傳型別(
Dog)可以取代較「抽象」的回傳型別(Animal)。
3. 可選參數與剩餘參數(Optional & Rest Parameters)
3.1 可選參數
在函式相容性中,可選參數視為更寬鬆的參數。換句話說,接受較少參數的函式可以被接受較多參數的函式取代。
type Greeter = (msg: string, times?: number) => void;
const simpleGreet: Greeter = (msg) => console.log(msg);
// 合法:simpleGreet 只接受一個必填參數,符合 Greeter 的需求
若相反,函式必須接受 times 參數,但被指派給只接受 msg 的函式,則會錯誤:
const strictGreet: (msg: string, times: number) => void = (msg, times) => {
console.log(msg, times);
};
// ❌ TypeScript 錯誤:'strictGreet' 不能分配給類型 'Greeter'
const invalid: Greeter = strictGreet;
3.2 剩餘參數
剩餘參數 (...args) 本質上是一個陣列型別,會自動擴展為「任意數量」的參數。相容性規則同樣遵循逆變(對參數)與協變(對回傳值)。
type Sum = (...nums: number[]) => number;
const sumAll: Sum = (...nums) => nums.reduce((a, b) => a + b, 0);
如果將 Sum 的參數改為 string[],則會產生錯誤,因為 number[] 不能分配給 string[]。
4. 函式重載(Function Overloads)
TypeScript 允許同一個函式有多個簽名(overload)。相容性檢查會針對 每一個 overload 逐一驗證,確保實作符合所有宣告的型別。
function fetchData(id: number): Promise<string>;
function fetchData(url: string): Promise<string>;
function fetchData(arg: number | string): Promise<string> {
// 內部實作
return Promise.resolve(typeof arg === "number"
? `data for id ${arg}`
: `data from ${arg}`);
}
// 正確使用
fetchData(42).then(console.log);
fetchData("https://api.example.com").then(console.log);
若實作缺少其中一個 overload 的支援,編譯器會提示錯誤。
程式碼範例
以下提供 5 個實用範例,說明函式相容性在日常開發中的具體應用。每個範例都包含註解,方便讀者快速掌握要點。
範例 1:事件處理器的相容性
// 事件基礎型別
interface MouseEvent {
x: number;
y: number;
}
interface KeyboardEvent {
key: string;
}
// 只接受 MouseEvent 的處理器
type MouseHandler = (e: MouseEvent) => void;
// 可接受任意 Event 的通用處理器
type AnyHandler = (e: MouseEvent | KeyboardEvent) => void;
// 合法:更專屬的處理器可以指派給更寬鬆的型別
const handleMouse: MouseHandler = (e) => console.log(e.x, e.y);
const generic: AnyHandler = handleMouse; // ✅
/* 錯誤示範:把只能處理 KeyboardEvent 的函式指派給 MouseHandler
const handleKeyboard: AnyHandler = (e) => {
if ('key' in e) console.log(e.key);
};
const illegal: MouseHandler = handleKeyboard; // ❌ TypeScript 錯誤
*/
範例 2:回傳子類別的工廠函式
class Shape {
area() { return 0; }
}
class Circle extends Shape {
constructor(public radius: number) { super(); }
area() { return Math.PI * this.radius ** 2; }
}
// 回傳 Shape 的工廠
type ShapeFactory = () => Shape;
// 合法:回傳 Circle(Shape 的子類別)
const createCircle: ShapeFactory = () => new Circle(5);
範例 3:可選參數的相容性
type Logger = (msg: string, level?: "info" | "warn" | "error") => void;
// 只接受必填參數的實作仍然相容
const simpleLogger: Logger = (msg) => console.log("[INFO]", msg);
// 若實作要求必填 level,則不相容
const strictLogger: (msg: string, level: "info" | "warn" | "error") => void =
(msg, level) => console.log(`[${level.toUpperCase()}]`, msg);
// ❌ strictLogger 不能指派給 Logger
// const bad: Logger = strictLogger;
範例 4:剩餘參數與陣列相容性
type Concatenator = (...parts: string[]) => string;
const joinWithDash: Concatenator = (...parts) => parts.join("-");
// 合法:傳入任意長度的字串陣列
const result = joinWithDash("a", "b", "c"); // "a-b-c"
範例 5:函式重載的實作相容性
// 兩個簽名:接受數字或字串,回傳 Promise<string>
function load(id: number): Promise<string>;
function load(url: string): Promise<string>;
function load(arg: number | string): Promise<string> {
// 實作必須同時支援兩種型別
return Promise.resolve(
typeof arg === "number"
? `Data for id ${arg}`
: `Data from ${arg}`
);
}
// 呼叫時會自動推斷正確的回傳型別
load(10).then(console.log); // Data for id 10
load("https://api.site/data").then(console.log); // Data from https://api.site/data
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / 最佳實踐 |
|---|---|---|
| 把較嚴格的參數指派給較寬鬆的函式 | 逆變規則會導致編譯錯誤。 | 使用泛型 或 重載 讓型別更彈性;必要時使用 any(慎用)。 |
| 回傳值過於抽象 | 協變要求回傳型別必須是目標型別的子類別。 | 明確指定子類別,或使用 型別斷言(as)在確定安全的情況下。 |
| 可選參數與必填參數混用 | 可選參數會讓函式變得更寬鬆,若不小心把必填參數當可選,會失去型別保護。 | 盡量保持 API 一致性:若參數有預設值,使用可選參數;若必須提供,則保持必填。 |
| 剩餘參數與陣列型別不一致 | ...args: T[] 與 T[] 在相容性上有細微差異。 |
統一使用剩餘參數 或 明確轉換(as const)避免誤判。 |
| 重載實作不完整 | 實作未覆蓋所有簽名會導致編譯錯誤或執行時錯誤。 | 先寫簽名,再實作,或使用 條件型別 讓實作自動匹配。 |
最佳實踐總結
- 遵循「逆變」與「協變」原則:參數越寬鬆越好,回傳值越具體越好。
- 盡量使用具體型別:避免過度使用
any或unknown,除非真的需要動態型別。 - 利用泛型提升彈性:例如
type Mapper<T, U> = (value: T) => U;讓函式在不同型別間自由轉換。 - 保持 API 穩定:在公共函式庫中,對可選參數和重載要有明確的文件說明,避免使用者遭遇型別不相容的問題。
- 寫測試:型別相容性雖然在編譯階段檢查,但仍建議寫單元測試驗證行為一致性。
實際應用場景
1. UI 框架的事件系統
在 React、Vue 等框架中,事件處理器常以 event: SyntheticEvent 為參數。若開發者自行擴充事件型別(如自訂 DragEvent),只要符合逆變規則,即可安全地把自訂處理器指派給框架提供的 onDrag 屬性。
type DragHandler = (e: DragEvent) => void;
const myDrag: DragHandler = (e) => console.log(e.x, e.y);
<div onDrag={myDrag} />; // ✅ 相容
2. 資料庫存取層的工廠函式
資料存取層常以 工廠函式 產生模型實例。利用回傳值的協變,我們可以把返回子類別(例如 UserEntity)的工廠指派給返回基礎類別 BaseEntity 的介面,讓上層服務只依賴抽象型別。
interface BaseEntity { id: string; }
class UserEntity implements BaseEntity { id = ""; name = ""; }
type EntityFactory = () => BaseEntity;
const userFactory: EntityFactory = () => new UserEntity(); // ✅
3. Middleware 管線的可插拔設計
在 Express、Koa 等 Node 框架,middleware 為 (ctx, next) => Promise<void>。若某個 middleware 只接受 ctx 而不需要 next,仍可相容於完整簽名,因為 next 為可選參數(或可忽略)。
type Middleware = (ctx: Context, next: () => Promise<void>) => Promise<void>;
const logger: Middleware = async (ctx, next) => {
console.log(ctx.path);
await next();
};
const simple: Middleware = async (ctx) => {
console.log("only ctx");
}; // ✅ 因為 next 為可選
總結
函式相容性是 TypeScript 型別系統 中不可或缺的概念,它透過 逆變(參數)與 協變(回傳值)兩大原則,為函式的替換與組合提供安全保障。掌握以下要點,即可在開發過程中:
- 避免型別錯誤:正確判斷何時可以把較寬鬆的函式指派給較嚴格的型別,或反之。
- 提升程式彈性:利用可選參數、剩餘參數與泛型,寫出能適應多種情境的 API。
- 保持代碼可讀與可維護:遵循最佳實踐,寫出清晰的函式簽名與完整的重載實作。
在實務上,從 UI 事件處理、工廠模式到 middleware 管線,函式相容性都扮演著關鍵角色。只要熟練掌握這套規則,您就能在 TypeScript 生態系中寫出 安全、彈性且易於擴充 的程式碼。祝開發愉快! 🎉