TypeScript – 型別相容性與型別系統:名稱相容性(Nominal vs Structural)
簡介
在 TypeScript 中,型別相容性(type compatibility)是編譯器判斷兩個型別是否可以互相指派的核心機制。相容性的判斷方式直接影響程式的可讀性、可維護性與安全性。
大多數程式語言(如 Java、C#)採用 名義相容(Nominal typing):型別的相容性取決於它們的宣告名稱;而 TypeScript 採用了 結構相容(Structural typing),即只要結構相同就視為相容。這兩種概念的差異看似抽象,卻在實務開發中常常決定了我們要如何設計 API、模型與函式介面。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你了解 名義相容與結構相容的差異,並提供在真實專案中應用的方向。
核心概念
1. 名義相容(Nominal Typing)
名義相容要求型別的名稱必須相同或有明確的繼承關係,才能互相指派。換句話說,即使兩個型別的結構完全相同,只要名稱不同,編譯器仍會視為不相容。
| 語言 | 典型實作 |
|---|---|
| Java | class Person { name: string } vs class Employee { name: string } 兩者不可互換 |
| C# | struct Point { x: number; y: number } vs struct Vector { x: number; y: number } 仍不相容 |
在 TypeScript 中,預設是結構相容,但我們可以透過 unique symbol 或 brand 技巧模擬名義相容。
2. 結構相容(Structural Typing)
結構相容只關注型別的形狀(properties & methods)。只要兩個型別的結構相同,即可互相指派。這也是 TypeScript 被稱為「型別推斷」友好語言的根本原因。
interface Point {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
let p: Point = { x: 0, y: 0 };
let v: Vector = p; // ✅ 允許,結構相同
3. 為什麼 TypeScript 採用結構相容?
- 開發體驗友好:不必為每個型別寫大量的繼承或介面,減少樣板程式碼。
- 與 JavaScript 本身的動態特性相容:JS 只在執行時檢查屬性是否存在,TypeScript 的結構相容正好映射這種行為。
- 提升重用性:同一個函式可以接受多種「形狀」相同的參數,降低耦合度。
程式碼範例
以下範例展示 結構相容、名義相容的模擬,以及在實務中常見的使用情境。
範例 1:基本結構相容
// 定義兩個看似不同的介面
interface UserDTO {
id: number;
name: string;
}
interface UserEntity {
id: number;
name: string;
}
// 直接指派,編譯通過
const dto: UserDTO = { id: 1, name: "Alice" };
let entity: UserEntity = dto; // ✅ 結構相同即相容
重點:只要屬性名稱與型別相同,TypeScript 便視為相容,無須額外的
extends。
範例 2:使用 unique symbol 建立名義相容(Branding)
// 建立唯一的 Symbol 作為品牌
declare const UserIdBrand: unique symbol;
type UserId = number & { [UserIdBrand]: never };
function createUserId(id: number): UserId {
return id as UserId; // 斷言為品牌型別
}
// 正常的 number 不能直接指派為 UserId
let rawId: number = 5;
// let uid: UserId = rawId; // ❌ 編譯錯誤
let uid: UserId = createUserId(rawId); // ✅ 正確使用品牌
說明:透過
unique symbol,UserId與普通的number雖然結構相同,但在編譯階段被視為不同型別,達到名義相容的效果。
範例 3:結構相容之下的「寬鬆」與「嚴格」函式參數
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
// 只需要 x, y 的函式
function distance2D(p: Point2D): number {
return Math.sqrt(p.x ** 2 + p.y ** 2);
}
const p3: Point3D = { x: 1, y: 2, z: 3 };
distance2D(p3); // ✅ 結構相容,額外的屬性不會阻礙
注意:在嚴格模式(
--strictFunctionTypes)下,回傳型別相容仍遵循結構規則,但 函式參數 的相容性會更保守,避免「逆變」問題。
範例 4:模擬名義相容的類別(使用 private 成員)
class OrderId {
private readonly __brand!: void; // 私有成員讓型別唯一
constructor(public readonly value: string) {}
}
class InvoiceId {
private readonly __brand!: void;
constructor(public readonly value: string) {}
}
let orderId = new OrderId("A001");
let invoiceId = new InvoiceId("B001");
// orderId = invoiceId; // ❌ 錯誤,因為兩者的私有成員不同,形成名義相容
技巧:在類別裡加入一個私有屬性,可讓 TypeScript 把兩個結構相同的類別視為不同型別,達到名義相容的效果。
範例 5:實務中「DTO ↔️ Entity」的相容性
// 從資料庫取出的 Entity
interface UserEntity {
id: number;
name: string;
createdAt: Date;
}
// 前端傳入的 DTO(不包含 createdAt)
interface CreateUserDTO {
name: string;
}
// 轉換函式
function toEntity(dto: CreateUserDTO): Omit<UserEntity, "id" | "createdAt"> {
return {
name: dto.name,
};
}
// 使用情境
const dto: CreateUserDTO = { name: "Bob" };
const partial: Omit<UserEntity, "id" | "createdAt"> = toEntity(dto);
// 之後再補上 id、createdAt
說明:透過
Omit、Pick等工具型別,我們可以在保持結構相容的同時,明確表達「哪些欄位是必須、哪些是可省」的意圖。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 過度寬鬆的結構相容 | 允許額外屬性可能導致不小心把錯誤的物件傳入函式 | 使用 Exact 型別(自訂型別)或在 tsconfig.json 開啟 noImplicitAny、strict。 |
| 品牌(Brand)遺失 | 若忘記在所有相關型別加入品牌,會導致型別退化回普通結構相容 | 建議將品牌封裝成 type alias 或 utility function,統一使用。 |
| 類別私有成員的意外衝突 | 不同類別若意外共享相同私有欄位名稱,仍會被視為相容 | 使用 private readonly __brand: unique symbol,確保唯一性。 |
| 函式參數的逆變問題 | 在嚴格函式相容模式下,子類別的參數型別不能寬鬆指派給父類別 | 開啟 strictFunctionTypes,讓編譯器檢查逆變,避免潛在錯誤。 |
any/unknown 的濫用 |
直接使用 any 會繞過所有相容檢查,失去 TypeScript 的安全保障 |
儘量使用 unknown 搭配型別保護(type guards)或 泛型。 |
最佳實踐:
- 先以結構相容為基礎設計 API,保持彈性與可重用性。
- 若需要嚴格區分不同概念(例如
UserId、OrderId),使用 品牌(brand) 或unique symbol產生名義相容。 - 在公共函式庫中,盡量使用 介面(interface) 而非 類別(class),因為介面天然支援結構相容,降低耦合。
- 開啟 TypeScript 嚴格模式(
"strict": true),讓相容檢查更加完整。 - 利用工具型別(
Pick、Omit、Partial),在保持結構相容的同時,表達「部份必填」的需求。
實際應用場景
1. 前後端資料傳輸(DTO ↔️ Domain Model)
在大型專案中,前端傳送的 DTO 常常只包含必要欄位,而後端的 Domain Model 可能多了 createdAt、updatedAt 等屬性。透過結構相容,我們可以直接把 DTO 用於 Domain Model 的子集,並在服務層自行補足缺失欄位。
2. 多個第三方套件的相同回傳結構
假設兩個不同的 HTTP 客戶端(axios、fetch)回傳的 JSON 結構相同,我們只需要定義一次介面:
interface ApiResponse<T> {
data: T;
status: number;
}
無論是哪個套件,只要回傳的物件符合 ApiResponse<User>,就能直接使用。
3. 防止 ID 混用
在金融或電商系統,UserId、ProductId、OrderId 皆為 number,若不加以區分,容易產生 錯誤的關聯。透過品牌型別,我們可以在編譯階段捕捉這類錯誤:
type UserId = number & { readonly __brand: unique symbol };
type ProductId = number & { readonly __brand: unique symbol };
任意把 UserId 指派給 ProductId 都會報錯,降低業務邏輯錯誤。
4. 插件系統的擴充
在設計插件系統時,核心程式只需要知道插件提供的 方法簽名,而不必關心插件的實作類別。結構相容允許開發者只要符合介面,就能無縫掛載:
interface Plugin {
init(app: App): void;
destroy?(): void;
}
任何符合 init 方法的物件,都可被視為合法插件。
總結
- 結構相容是 TypeScript 的預設行為,讓我們可以以「形狀」為基礎快速寫程式,提升開發效率。
- 名義相容則提供了在需要嚴格區分概念時的手段,最常透過
unique symbol、**品牌(brand)**或 類別的私有成員 來模擬。 - 在實務開發中,先採用結構相容設計 API,再根據業務需求使用品牌或私有成員加上名義相容的保護,能兼顧彈性與安全。
- 開啟 TypeScript 的 嚴格模式、善用工具型別與品牌技巧,是避免相容性錯誤、提升程式碼品質的關鍵。
掌握了 Nominal vs Structural 的差異與應用,你就能在 TypeScript 專案中更自信地設計型別、撰寫函式介面,並有效防止因型別混用而產生的隱藏錯誤。祝你寫程式愉快,型別無所不能!