本文 AI 產出,尚未審核

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 採用結構相容?

  1. 開發體驗友好:不必為每個型別寫大量的繼承或介面,減少樣板程式碼。
  2. 與 JavaScript 本身的動態特性相容:JS 只在執行時檢查屬性是否存在,TypeScript 的結構相容正好映射這種行為。
  3. 提升重用性:同一個函式可以接受多種「形狀」相同的參數,降低耦合度。

程式碼範例

以下範例展示 結構相容、名義相容的模擬,以及在實務中常見的使用情境。

範例 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)或 泛型。

最佳實踐:

  1. 先以結構相容為基礎設計 API,保持彈性與可重用性。
  2. 若需要嚴格區分不同概念(例如 UserId、OrderId),使用 品牌(brand) 或 unique symbol 產生名義相容。
  3. 在公共函式庫中,盡量使用 介面(interface) 而非 類別(class),因為介面天然支援結構相容,降低耦合。
  4. 開啟 TypeScript 嚴格模式("strict": true),讓相容檢查更加完整。
  5. 利用工具型別(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 專案中更自信地設計型別、撰寫函式介面,並有效防止因型別混用而產生的隱藏錯誤。祝你寫程式愉快,型別無所不能!