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("系統啟動");
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議做法 |
|---|---|---|
| 過度寬鬆的型別 | 使用 any、object 或過度泛型會失去結構化檢查的好處。 |
儘量使用具體的屬性描述,必要時使用 unknown 再自行斷言。 |
| 可選屬性與必選屬性混用 | 把可選屬性當成必選使用會在執行時產生 undefined 錯誤。 |
在使用前加上 nullish coalescing (??) 或 條件檢查。 |
| 只讀屬性被意外改寫 | 只讀屬性在結構相容時仍可被更寬鬆的型別覆寫。 | 若要保護資料,使用 readonly 並在函式簽名中保持只讀。 |
| 函式參數逆變誤解 | 把接受較嚴格參數的函式指派給接受較寬鬆參數的變數會編譯錯誤。 | 理解 逆變 原則,必要時使用泛型或 overload 來調整。 |
| 索引簽名過寬 | [{ [key: string]: any }] 會讓編譯器失去檢查能力。 |
限定索引值型別(如 number、string)並盡量指定具體屬性。 |
最佳實踐
- 先寫介面,再實作:以介面描述預期結構,讓 IDE 提供自動完成與錯誤提示。
- 利用型別推斷:讓 TypeScript 從實際值推斷結構,減少冗餘宣告。
- 適度使用
as const:將字面量提升為只讀,避免意外變更。 - 保持型別一致性:在大型專案中,建立共用的型別庫(
types/*.d.ts),避免同一結構多次定義。
實際應用場景
第三方函式庫的整合
多數 npm 套件只提供型別宣告檔 (*.d.ts);只要你的物件符合宣告的結構,即可直接使用,無需繼承或手動轉型。React Props 的傳遞
React 元件的props本質上是結構化型別。使用介面描述props,只要傳入的屬性符合結構,即可安全渲染。interface ButtonProps { label: string; onClick?: () => void; } const Button: React.FC<ButtonProps> = ({ label, onClick }) => ( <button onClick={onClick}>{label}</button> );微服務間的資料交換
透過 JSON 介面描述資料結構,接收端只要檢查屬性是否齊全即可,不必關心來源物件的實際類別。插件系統(Plugin Architecture)
插件只需要實作預先定義好的介面(例如initialize(app: App): void),主程式不需要知道插件的具體類別,只要結構相容即可載入。
總結
結構化型別是 TypeScript 最具威力的特性之一,它讓 「只要形狀相同,就能互換」 成為可能。透過介面、型別別名、索引簽名與交叉/聯合型別,我們可以在保留靜態型別安全的同時,寫出彈性高、可重用的程式碼。
在實務開發中,善用結構相容的原則可以減少不必要的繼承層級、提升第三方套件的整合效率,並讓大型專案的型別維護更為簡潔。記得避免過度寬鬆的型別、正確處理可選與只讀屬性,並在需要時適度加入型別斷言或 as const,即可在 安全 與 彈性 之間取得最佳平衡。祝你在 TypeScript 的型別世界裡玩得開心!