本文 AI 產出,尚未審核

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)避免誤判。
重載實作不完整 實作未覆蓋所有簽名會導致編譯錯誤或執行時錯誤。 先寫簽名,再實作,或使用 條件型別 讓實作自動匹配。

最佳實踐總結

  1. 遵循「逆變」與「協變」原則:參數越寬鬆越好,回傳值越具體越好。
  2. 盡量使用具體型別:避免過度使用 anyunknown,除非真的需要動態型別。
  3. 利用泛型提升彈性:例如 type Mapper<T, U> = (value: T) => U; 讓函式在不同型別間自由轉換。
  4. 保持 API 穩定:在公共函式庫中,對可選參數和重載要有明確的文件說明,避免使用者遭遇型別不相容的問題。
  5. 寫測試:型別相容性雖然在編譯階段檢查,但仍建議寫單元測試驗證行為一致性。

實際應用場景

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 生態系中寫出 安全、彈性且易於擴充 的程式碼。祝開發愉快! 🎉