本文 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 symbolbrand 技巧模擬名義相容。

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 symbolUserId 與普通的 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

說明:透過 OmitPick 等工具型別,我們可以在保持結構相容的同時,明確表達「哪些欄位是必須、哪些是可省」的意圖。


常見陷阱與最佳實踐

陷阱 說明 解決方案
過度寬鬆的結構相容 允許額外屬性可能導致不小心把錯誤的物件傳入函式 使用 Exact 型別(自訂型別)或在 tsconfig.json 開啟 noImplicitAnystrict
品牌(Brand)遺失 若忘記在所有相關型別加入品牌,會導致型別退化回普通結構相容 建議將品牌封裝成 type aliasutility function,統一使用。
類別私有成員的意外衝突 不同類別若意外共享相同私有欄位名稱,仍會被視為相容 使用 private readonly __brand: unique symbol,確保唯一性。
函式參數的逆變問題 在嚴格函式相容模式下,子類別的參數型別不能寬鬆指派給父類別 開啟 strictFunctionTypes,讓編譯器檢查逆變,避免潛在錯誤。
any/unknown 的濫用 直接使用 any 會繞過所有相容檢查,失去 TypeScript 的安全保障 儘量使用 unknown 搭配型別保護(type guards)或 泛型

最佳實踐

  1. 先以結構相容為基礎設計 API,保持彈性與可重用性。
  2. 若需要嚴格區分不同概念(例如 UserIdOrderId),使用 品牌(brand)unique symbol 產生名義相容。
  3. 在公共函式庫中,盡量使用 介面(interface) 而非 類別(class),因為介面天然支援結構相容,降低耦合。
  4. 開啟 TypeScript 嚴格模式"strict": true),讓相容檢查更加完整。
  5. 利用工具型別(PickOmitPartial,在保持結構相容的同時,表達「部份必填」的需求。

實際應用場景

1. 前後端資料傳輸(DTO ↔️ Domain Model)

在大型專案中,前端傳送的 DTO 常常只包含必要欄位,而後端的 Domain Model 可能多了 createdAtupdatedAt 等屬性。透過結構相容,我們可以直接把 DTO 用於 Domain Model 的子集,並在服務層自行補足缺失欄位。

2. 多個第三方套件的相同回傳結構

假設兩個不同的 HTTP 客戶端(axiosfetch)回傳的 JSON 結構相同,我們只需要定義一次介面:

interface ApiResponse<T> {
  data: T;
  status: number;
}

無論是哪個套件,只要回傳的物件符合 ApiResponse<User>,就能直接使用。

3. 防止 ID 混用

在金融或電商系統,UserIdProductIdOrderId 皆為 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 專案中更自信地設計型別、撰寫函式介面,並有效防止因型別混用而產生的隱藏錯誤。祝你寫程式愉快,型別無所不能!