本文 AI 產出,尚未審核

TypeScript – 物件與介面(Objects & Interfaces)

主題:結構化型別系統(Structural Typing)


簡介

在 JavaScript 的世界裡,一切皆是「物件」;而在 TypeScript 中,我們則藉由型別系統讓這些物件更安全、更可預測。
結構化型別系統(Structural Typing) 是 TypeScript 最核心的特性之一,它讓型別的相容性不是靠「宣告」的名稱,而是依賴「形狀(shape)」——也就是物件的屬性與方法。

掌握結構化型別的概念,能讓你在撰寫大型前端或 Node.js 專案時,減少型別錯誤、提升程式的可重用性,甚至在與第三方函式庫、API 互動時,得到更好的開發體驗。本文將從概念說明、實作範例、常見陷阱與最佳實踐,一路帶你深入了解這項功能,並提供實務應用的情境。


核心概念

1. 什麼是結構化型別?

在傳統的「名義型別(Nominal Typing)」(如 Java、C#)中,兩個型別即使結構相同,只要名稱不同,就不會被視為相容。
Structural Typing 則是「看形狀」:只要一個物件具備所需的屬性與方法,就可以被視為符合該型別。

舉例

interface Point2D { x: number; y: number; }
interface Coordinate { x: number; y: number; }
// 雖然兩個介面名稱不同,只要物件同時擁有 x、y 兩個 number 屬性,就能相互指派
const p: Point2D = { x: 10, y: 20 };
const c: Coordinate = p;   // ✅ 合法

2. 介面(Interface)與型別別名(type alias)的差異

特色 interface type
可擴充(declaration merging) ✅ 可多次宣告合併 ❌ 只能一次定義
支援交叉、聯合型別 ✅(搭配 &| ✅(同樣支援)
可用於類別實作 ❌(只能作為型別)

在結構化型別的語境下,兩者皆會依照結構來判斷相容性,選擇哪一個主要看語意與未來擴充需求。

3. 可選屬性與只讀屬性

  • 可選屬性 (?) 代表該屬性在物件中可以不存在。
  • 只讀屬性 (readonly) 代表屬性在建立後不可被修改。
interface User {
  readonly id: number;   // 只能在建立時指定
  name: string;
  email?: string;        // 可能不存在
}

即使屬性是 可選的,只要在使用時提供,就會影響型別相容性判斷。

4. 函式型別的結構化比較

函式在 TypeScript 中也是型別,結構化比較同樣適用。

  • 參數的數量類型順序必須相容。
  • 回傳型別也必須兼容。
type Comparator = (a: number, b: number) => number;

const ascend: Comparator = (x, y) => x - y;        // ✅
const descend = (x: number, y: number): number => y - x; // ✅(結構相同)

// 多餘參數不會破壞相容性(只要前面的相容)
const logger: Comparator = (x, y, ...rest) => {
  console.log(rest); // rest 被忽略
  return x - y;
};

5. 例外:anyunknown 與結構化型別

  • any跳過所有型別檢查,等同於關閉結構化比較。
  • unknown:雖然是安全的「未知」型別,但在賦值給具體型別前,需要進行類型縮小(type guard)或斷言。
let a: any = { foo: "bar" };
let p: Point2D = a; // ✅ 任意賦值

let u: unknown = { x: 1, y: 2 };
let q: Point2D = u; // ❌ 編譯錯誤,必須先確認
if (typeof u === "object" && u !== null && "x" in u && "y" in u) {
  q = u as Point2D; // ✅ 透過斷言或 type guard
}

程式碼範例

以下示範 5 個常見且實用的範例,說明結構化型別在日常開發中的運用。

範例 1:簡易資料轉換(DTO ↔ Domain Model)

// 資料傳輸物件(DTO)來自後端 API
interface UserDTO {
  id: number;
  full_name: string;
  email?: string;
}

// Domain Model 在前端使用的型別
interface User {
  id: number;
  name: string;
  email: string; // 必填,若 DTO 沒有則給預設值
}

/**
 * 把 DTO 轉成 Domain Model
 * 只要結構相容,就可以直接指派,再做少量調整
 */
function toUser(dto: UserDTO): User {
  // 直接利用結構相容性,先把共同屬性指派過去
  const base: User = { id: dto.id, name: dto.full_name, email: "" };
  // 再補足 optional 欄位
  if (dto.email) base.email = dto.email;
  return base;
}

範例 2:函式作為參數的結構化檢查

type Mapper<T, U> = (value: T) => U;

function mapArray<T, U>(arr: T[], fn: Mapper<T, U>): U[] {
  return arr.map(fn);
}

// 使用時,只要傳入符合 (value: T) => U 結構的函式即可
const numbers = [1, 2, 3];
const strings = mapArray(numbers, n => `Number ${n}`); // ✅

範例 3:混合型別(Intersection Types)打造彈性物件

interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

interface Identifiable {
  id: string;
}

// 透過交叉型別,同時擁有兩個介面的屬性
type Entity = Timestamped & Identifiable;

const article: Entity = {
  id: "a1b2c3",
  createdAt: new Date(),
  updatedAt: new Date(),
};

範例 4:泛型介面與結構化相容

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

// 只要結構相符,即可把具體型別套用在泛型上
type UserResponse = ApiResponse<User>;

const resp: UserResponse = {
  data: { id: 1, name: "Alice", email: "alice@example.com" },
  status: 200,
};

範例 5:利用 readonly 防止意外變更

interface Config {
  readonly apiUrl: string;
  timeout: number;
}

function fetchData(cfg: Config) {
  // cfg.apiUrl 無法被重新指派,提供安全性
  console.log(`向 ${cfg.apiUrl} 發送請求`);
  // cfg.apiUrl = "http://malicious.com"; // ❌ 編譯錯誤
}

// 呼叫時,若傳入的物件屬性符合結構即可
const myCfg = { apiUrl: "https://api.example.com", timeout: 5000 };
fetchData(myCfg);

常見陷阱與最佳實踐

陷阱 說明 解決方案
過度寬鬆的型別 只要結構符合,即使不小心把不相關的物件傳入也會通過。 使用 exact optional property types(TS 4.4+)或 as const 斷言,限制字面量型別。
可選屬性造成隱性錯誤 讀取可選屬性前未檢查,會得到 undefined 在使用前加入 null/undefined 檢查預設值 (??、`
函式參數多餘 多餘參數不會觸發錯誤,可能掩蓋錯誤的 API 設計。 noImplicitAnystrictFunctionTypes 讓多餘參數必須明確標記 (...args: any[])。
any 濫用 失去型別檢查的好處。 儘量改用 unknown,並在需要時使用 type guard
介面合併意外衝突 多次宣告同名介面時,屬性衝突會合併,可能產生不預期的型別。 在大型專案中,使用 type命名空間 來避免不必要的合併。

最佳實踐

  1. 以最小需求描述介面:只列出必須的屬性,讓物件更具彈性。
  2. 利用 readonly 保護不變資料:尤其是從 API 取得的回應,避免意外改寫。
  3. 結合 Partial<T>Pick<T,K>Omit<T,K> 產生變形型別,提升重用性。
  4. 啟用嚴格模式 (strict: true):確保結構化型別的檢查不被寬鬆設定所削弱。
  5. 寫型別守衛(type guard):在處理 unknownany 或外部資料時,明確縮小型別範圍。

實際應用場景

1. 前端 UI 組件庫的 Props 定義

在 React、Vue 等框架中,組件的 props 常以介面描述。透過結構化型別,使用者只要提供具備相同屬性的物件,即可傳遞給組件,無需完全相同的介面名稱。

interface ButtonProps {
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

const MyButton: React.FC<ButtonProps> = ({ label, onClick, disabled }) => (
  <button onClick={onClick} disabled={disabled}>{label}</button>
);

// 呼叫時,直接傳入符合結構的物件
const config = { label: "送出", onClick: () => console.log("click") };
<MyButton {...config} />; // ✅ 合法

2. 後端微服務間的 DTO 共享

多個微服務使用相同的資料結構(如 UserOrder),但各自維護自己的介面檔案。只要結構相同,就能在 TypeScript 中直接相互指派,避免重複定義。

3. 第三方函式庫的型別擴充

許多 npm 套件提供 宣告檔 (.d.ts);若需要加入自訂屬性,只要符合原始介面的結構即可,透過 模組擴充(module augmentation) 實現。

import "express";

declare module "express" {
  interface Request {
    user?: User; // 只要結構相符,即可在中介軟體中掛載
  }
}

4. 動態表單與驗證

使用 YupZod 等驗證函式庫時,會先產生一個「資料結構」的描述。透過結構化型別,我們可以把驗證結果直接映射成 TypeScript 型別,確保後續程式碼的安全。


總結

結構化型別是 TypeScript 與 JavaScript 之間最具力量的橋樑。它讓開發者只要關注 資料的形狀,就能在不受類別名稱限制的情況下,享受到靜態型別帶來的安全與自動完成支援。

  • 概念:型別相容性基於屬性與方法的結構,而非名稱。
  • 實作:介面、型別別名、交叉/聯合型別、函式型別皆遵循結構比較。
  • 實務:在 UI 組件、API DTO、微服務、第三方套件擴充等場景中,結構化型別提供了彈性與可維護性。
  • 陷阱:過寬的型別、可選屬性未檢查、any 濫用等,需要透過嚴格設定與型別守衛加以防範。

只要遵循 「描述最小需求」「保護不可變資料」「啟用嚴格模式」 三大原則,你就能在日常開發中,充分利用結構化型別的優勢,寫出更可靠、可維護的 TypeScript 程式碼。祝你寫程式愉快,型別安全!