本文 AI 產出,尚未審核

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

主題:Object 型別定義


簡介

TypeScript 中,物件是最常見的資料結構,幾乎所有的程式都會與物件互動。
與純 JavaScript 不同,TypeScript 允許我們在編譯階段就 描述物件的形狀(屬性名稱、類型、是否必填),藉此提前捕捉錯誤、提升 IDE 補全與文件可讀性。

本篇將說明 Object 型別定義 的核心概念、常見寫法與實務技巧,幫助讀者從「只能寫 any」的狀態,逐步建立 安全、可維護 的物件型別。


核心概念

1. 基本的物件型別寫法

TypeScript 允許直接在變數宣告時以 物件字面量型別(object literal type)描述屬性:

// 只允許 name 為字串、age 為數字,且兩者皆必填
let person: { name: string; age: number } = {
  name: "Alice",
  age: 30,
};
  • 大括號內的 name: string; age: number 就是 型別字面量,它描述了物件必須具備的屬性與對應型別。
  • 若少寫或寫錯屬性型別,編譯器會立即報錯。

Tip:在大型專案中,直接寫在變數上會讓程式碼變得雜亂,接下來會介紹更可重用的寫法。


2. 使用 type 建立別名

type 關鍵字可為物件型別取個易讀的別名,讓程式碼更具語意:

type User = {
  id: number;
  username: string;
  email?: string; // ? 表示屬性為可選
};

const admin: User = {
  id: 1,
  username: "admin",
  // email 可以省略
};
  • email?可選屬性,使用時不一定要提供。
  • type 允許 交叉型別&)與 聯合型別|)的組合,彈性極高。

3. 介面(interface)與型別別名的差異

interfacetype 都可以描述物件形狀,但 介面 有以下特點:

特點 interface type
擴充 支援 宣告合併(同名介面會自動合併) 只能透過交叉型別 (&) 手動合併
實作 可被 class implements 只能透過型別相容性檢查
可用於 物件、函式、陣列等多種結構 主要用於物件、聯合、交叉等
interface Point {
  x: number;
  y: number;
}

// 另一個同名宣告會自動合併
interface Point {
  z?: number; // 新增可選屬性
}

const p: Point = { x: 0, y: 0 }; // z 可以不寫

實務建議:若需要讓多個檔案共享同一個型別且可能在未來擴充,優先考慮 interface;若需要使用聯合或映射型別(mapped types),則使用 type


4. 只讀屬性與索引簽名

只讀屬性 (readonly)

type Config = {
  readonly apiUrl: string;
  timeout: number;
};

const cfg: Config = { apiUrl: "https://api.example.com", timeout: 5000 };
cfg.timeout = 3000; // ✅ 合法
// cfg.apiUrl = "https://new.example.com"; // ❌ 編譯錯誤
  • readonly 確保屬性在物件建立後 不可被修改,適合描述常數設定或不可變資料。

索引簽名(Index Signature)

當物件的屬性名稱不固定,卻有相同型別時,可使用索引簽名:

type Dictionary = {
  [key: string]: number; // 任意字串鍵對應數字值
};

const scores: Dictionary = {
  alice: 85,
  bob: 92,
  // key 可以是任何字串
};
  • 索引簽名讓我們 動態存取 任意屬性,同時仍保有型別安全。

5. 函式屬性與 this 型別

物件常會包含方法,TypeScript 允許在型別定義中明確描述 函式參數與回傳型別,甚至 this 的型別:

type Counter = {
  count: number;
  inc(step?: number): void;
  get(): number;
};

const counter: Counter = {
  count: 0,
  inc(step = 1) {
    this.count += step; // this 會自動推斷為 Counter
  },
  get() {
    return this.count;
  },
};

counter.inc(5);
console.log(counter.get()); // 5

若要手動指定 this 型別,可使用 this 參數

type Greeter = {
  name: string;
  greet(this: Greeter): string;
};

const greeter: Greeter = {
  name: "TS",
  greet() {
    return `Hello, ${this.name}!`;
  },
};

程式碼範例(實用示例)

以下範例均可直接在 TypeScript Playground 測試。

範例 1:使用 type + 交叉型別建構可擴充的設定物件

type BaseConfig = {
  apiKey: string;
  timeout: number;
};

type AdvancedConfig = BaseConfig & {
  retry?: number; // 可選的重試次數
  headers: Record<string, string>;
};

const cfg: AdvancedConfig = {
  apiKey: "12345",
  timeout: 3000,
  headers: { "Content-Type": "application/json" },
  // retry 可省略
};

範例 2:介面宣告合併與只讀屬性

interface User {
  id: number;
  name: string;
}

// 合併額外屬性
interface User {
  readonly createdAt: Date;
}

const u: User = { id: 1, name: "Bob", createdAt: new Date() };
// u.createdAt = new Date(); // ❌ 只能讀取

範例 3:索引簽名與動態屬性存取

type LocaleMap = {
  [lang: string]: { hello: string };
};

const locales: LocaleMap = {
  en: { hello: "Hello" },
  zh: { hello: "哈囉" },
};

function greet(lang: string) {
  const entry = locales[lang];
  return entry ? entry.hello : locales["en"].hello;
}

console.log(greet("zh")); // 哈囉

範例 4:函式屬性與 this 型別保護

type Timer = {
  start: number;
  stop(): void;
};

const timer: Timer = {
  start: Date.now(),
  stop() {
    console.log(`Elapsed: ${Date.now() - this.start}ms`);
  },
};

setTimeout(() => timer.stop(), 1000);

範例 5:結合介面與型別別名實作「策略模式」

// 策略介面
interface Strategy {
  execute(a: number, b: number): number;
}

// 具體策略
class Add implements Strategy {
  execute(a: number, b: number) {
    return a + b;
  }
}
class Multiply implements Strategy {
  execute(a: number, b: number) {
    return a * b;
  }
}

// Context 使用介面型別
type Context = {
  strategy: Strategy;
  compute(a: number, b: number): number;
};

const ctx: Context = {
  strategy: new Add(),
  compute(a, b) {
    return this.strategy.execute(a, b);
  },
};

console.log(ctx.compute(2, 3)); // 5
ctx.strategy = new Multiply();
console.log(ctx.compute(2, 3)); // 6

常見陷阱與最佳實踐

常見問題 為什麼會發生 解決方式
屬性名稱拼寫錯誤卻未被偵測 使用 any 或未明確宣告型別 避免 any,改用 unknown 或明確的物件型別。
可選屬性被誤用為必填 讀取可選屬性時未做 undefined 檢查 使用 可選鏈結 (?.)if (obj.prop !== undefined) 防止 runtime 錯誤。
介面合併導致屬性衝突 多個檔案同名介面定義不同型別 盡量在同一模組內完成介面定義,或使用 type 取代合併需求。
索引簽名過寬 [key: string]: any 失去型別保護 使用具體的值型別(如 numberboolean)或 Record<string, T>
this 型別推斷錯誤 方法被解構或傳遞時失去原本的 this 使用 箭頭函式 或在型別中明確寫 this: Type

最佳實踐

  1. 先定義介面或型別別名,再在程式中引用,保持程式碼 DRY(Don't Repeat Yourself)。
  2. 盡量使用 readonly 來保護不應被修改的資料,減少不可預期的副作用。
  3. 對外部 API 回傳的資料 使用 索引簽名 + unknown,再逐步窄化(type guard)以取得安全的屬性。
  4. 利用 Record<K, V> 替代手寫的 [key: K]: V,語意更清晰。
  5. 在大型專案中分層管理型別src/types 資料夾放置共用型別,介面放在 interfaces,避免循環依賴。

實際應用場景

場景 為何需要 Object 型別定義 範例
前端 UI 狀態管理(Redux、Pinia) 狀態物件結構必須一致,才能正確觸發 UI 更新 定義 AppState 介面,所有 reducer 必須回傳符合該介面。
後端 REST API 請求/回應 請求參數與回傳資料的型別必須對應,避免 runtime 錯誤 type CreateUserReq = { name: string; age?: number } 定義 POST body。
第三方套件設定 套件通常接受一個設定物件,型別描述能即時提醒缺少必填欄位 `interface ChartOptions { width: number; height: number; theme?: "dark"
資料庫模型映射(ORM) ORM 需要映射資料表欄位與 TypeScript 型別,保持資料一致性 使用 type UserModel = { id: number; email: string; createdAt: Date }
策略模式或插件系統 每個策略或插件必須遵守相同介面,才能在執行時互換 前述「策略模式」範例,Strategy 介面保證 execute 方法簽名一致。

總結

  • Object 型別定義 是 TypeScript 最基礎也是最威力的特性之一,能在開發階段即捕捉結構錯誤。
  • 透過 type 別名interface只讀屬性索引簽名函式型別,我們可以精確描述任何複雜的物件形狀。
  • 避免 any、善用 readonly、適時使用索引簽名與 Record,能提升程式碼的安全性與可維護性。
  • 在 UI 狀態管理、API 交互、設定物件、資料模型與策略模式等實務情境中,正確的物件型別定義是 防止 bug、提升開發效率 的關鍵。

最後提醒:寫好型別是開發的第一步,持續維護與適時重構 才能讓專案在長期演進中保持健康。祝你在 TypeScript 的世界裡寫出更安全、更有彈性的程式!