本文 AI 產出,尚未審核

TypeScript —— 型別相容性與型別系統

結構化型別(Structural Typing)


簡介

在 JavaScript 的世界裡,物件的形狀(屬性與方法)決定了它能否被當成另一個物件使用。TypeScript 延續了這個概念,稱為 結構化型別(Structural Typing)或 Duck Typing:只要兩個型別的結構相容,就可以互相指派,無需明確的繼承關係。
掌握結構化型別是理解 TypeScript 型別系統的關鍵,因為它直接影響程式碼的可重用性、可維護性與型別安全。即使你是剛接觸 TypeScript 的新手,只要了解「型別是看形狀」的原則,就能寫出更彈性的程式。


核心概念

1. 型別相容性的基本規則

  • 屬性集合:目標型別必須包含來源型別所有的必須屬性,且屬性型別相容。
  • 可選屬性:來源型別的可選屬性在目標型別中可以是必選或可選。
  • 只讀屬性:只讀屬性只能指派給同樣只讀或更寬鬆(可寫)的型別。

簡言之:只要「結構」相同,就能相容;繼承與介面名稱本身不會影響相容性。

2. 介面(Interface)與型別別名(Type Alias)的相容性

介面與型別別名在結構上是等價的,兩者都會被 TypeScript 以「結構」來比較。以下範例說明兩者可以互相指派,只要結構符合即可。

interface Point2D {
  x: number;
  y: number;
}

type Point3D = {
  x: number;
  y: number;
  z: number;
};

let p2: Point2D = { x: 1, y: 2 };
let p3: Point3D = { x: 1, y: 2, z: 3 };

// 只要結構相容,指派是合法的
p2 = p3;   // ✅ 多出的 z 屬性會被忽略
// p3 = p2; // ❌ 缺少 z 屬性,編譯錯誤

3. 函式型別的結構相容性

函式的參數與回傳值同樣遵循結構化規則。參數是逆變(contravariant)的,也就是說,接受較寬鬆參數的函式可以代替接受較嚴格參數的函式。

type Handler = (event: { type: string; payload: any }) => void;

const logHandler: Handler = (e) => console.log(e.type);

// 可以接受更具體的事件型別
type ClickEvent = { type: "click"; x: number; y: number };
const clickHandler: (e: ClickEvent) => void = logHandler; // ✅

type GenericEvent = { type: string };
const genericHandler: (e: GenericEvent) => void = clickHandler; // ❌

4. 可索引型別(Index Signature)

當型別具有索引簽名時,任意具備相同鍵值型別的物件皆可相容。

type Dictionary = { [key: string]: number };

let dict1: Dictionary = { a: 1, b: 2 };
let dict2 = { a: 1, b: 2, c: 3 }; // 結構相容

dict1 = dict2; // ✅

5. 交叉類型(Intersection)與聯合類型(Union)在相容性中的角色

交叉類型會把多個型別的結構合併,而聯合類型則允許任一成員型別。相容性檢查時,TypeScript 會先把交叉類型展開,再與目標型別比較。

type A = { a: number };
type B = { b: string };
type AB = A & B; // { a: number; b: string }

let ab: AB = { a: 10, b: "hi" };
let aOnly: A = ab; // ✅
let bOnly: B = ab; // ✅

程式碼範例

範例 1:簡易的「Duck Typing」檢查

type Quackable = { quack: () => void };

function makeItQuack(obj: Quackable) {
  obj.quack(); // 只要 obj 有 quack 方法就能呼叫
}

// 任意具備 quack 方法的物件皆可傳入
const duck = { quack: () => console.log("嘎嘎") };
const robot = { quack: () => console.log("機器鴨啟動") };
makeItQuack(duck);
makeItQuack(robot);

重點:不需要 implements Quackable,只要結構符合即可。


範例 2:可選屬性與只讀屬性的相容性

interface Config {
  readonly url: string;
  timeout?: number; // 可選
}

function fetchData(cfg: Config) {
  console.log(`Fetching ${cfg.url} with timeout ${cfg.timeout ?? "default"}`);
}

const cfg1 = { url: "https://api.example.com", timeout: 5000 };
const cfg2 = { url: "https://api.example.com" };

fetchData(cfg1); // ✅
fetchData(cfg2); // ✅

範例 3:函式相容性與參數逆變

type NumHandler = (n: number) => void;
type AnyHandler = (n: any) => void;

const handleAny: AnyHandler = (v) => console.log(v);
const handleNum: NumHandler = handleAny; // ✅ 可以接受更寬的 any

// 反向則會錯誤
// const invalid: AnyHandler = handleNum; // ❌

範例 4:使用索引簽名實作動態屬性

type Cache = { [key: string]: string | undefined };

const memo: Cache = {};

function setCache(key: string, value: string) {
  memo[key] = value;
}

function getCache(key: string): string | undefined {
  return memo[key];
}

範例 5:交叉型別的實務應用(Mixins)

type Logger = { log: (msg: string) => void };
type Timestamped = { timestamp: Date };

type Loggable = Logger & Timestamped;

const obj: Loggable = {
  log: (msg) => console.log(`[${obj.timestamp.toISOString()}] ${msg}`),
  timestamp: new Date(),
};

obj.log("系統啟動");

常見陷阱與最佳實踐

陷阱 說明 建議做法
過度寬鬆的型別 使用 anyobject 或過度泛型會失去結構化檢查的好處。 儘量使用具體的屬性描述,必要時使用 unknown 再自行斷言。
可選屬性與必選屬性混用 把可選屬性當成必選使用會在執行時產生 undefined 錯誤。 在使用前加上 nullish coalescing (??) 或 條件檢查
只讀屬性被意外改寫 只讀屬性在結構相容時仍可被更寬鬆的型別覆寫。 若要保護資料,使用 readonly 並在函式簽名中保持只讀。
函式參數逆變誤解 把接受較嚴格參數的函式指派給接受較寬鬆參數的變數會編譯錯誤。 理解 逆變 原則,必要時使用泛型或 overload 來調整。
索引簽名過寬 [{ [key: string]: any }] 會讓編譯器失去檢查能力。 限定索引值型別(如 numberstring)並盡量指定具體屬性。

最佳實踐

  1. 先寫介面,再實作:以介面描述預期結構,讓 IDE 提供自動完成與錯誤提示。
  2. 利用型別推斷:讓 TypeScript 從實際值推斷結構,減少冗餘宣告。
  3. 適度使用 as const:將字面量提升為只讀,避免意外變更。
  4. 保持型別一致性:在大型專案中,建立共用的型別庫(types/*.d.ts),避免同一結構多次定義。

實際應用場景

  1. 第三方函式庫的整合
    多數 npm 套件只提供型別宣告檔 (*.d.ts);只要你的物件符合宣告的結構,即可直接使用,無需繼承或手動轉型。

  2. React Props 的傳遞
    React 元件的 props 本質上是結構化型別。使用介面描述 props,只要傳入的屬性符合結構,即可安全渲染。

    interface ButtonProps {
      label: string;
      onClick?: () => void;
    }
    
    const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
      <button onClick={onClick}>{label}</button>
    );
    
  3. 微服務間的資料交換
    透過 JSON 介面描述資料結構,接收端只要檢查屬性是否齊全即可,不必關心來源物件的實際類別。

  4. 插件系統(Plugin Architecture)
    插件只需要實作預先定義好的介面(例如 initialize(app: App): void),主程式不需要知道插件的具體類別,只要結構相容即可載入。


總結

結構化型別是 TypeScript 最具威力的特性之一,它讓 「只要形狀相同,就能互換」 成為可能。透過介面、型別別名、索引簽名與交叉/聯合型別,我們可以在保留靜態型別安全的同時,寫出彈性高、可重用的程式碼。
在實務開發中,善用結構相容的原則可以減少不必要的繼承層級、提升第三方套件的整合效率,並讓大型專案的型別維護更為簡潔。記得避免過度寬鬆的型別、正確處理可選與只讀屬性,並在需要時適度加入型別斷言或 as const,即可在 安全彈性 之間取得最佳平衡。祝你在 TypeScript 的型別世界裡玩得開心!