本文 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)與型別別名的差異
interface 與 type 都可以描述物件形狀,但 介面 有以下特點:
| 特點 | 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 失去型別保護 |
使用具體的值型別(如 number、boolean)或 Record<string, T>。 |
this 型別推斷錯誤 |
方法被解構或傳遞時失去原本的 this |
使用 箭頭函式 或在型別中明確寫 this: Type。 |
最佳實踐
- 先定義介面或型別別名,再在程式中引用,保持程式碼 DRY(Don't Repeat Yourself)。
- 盡量使用
readonly來保護不應被修改的資料,減少不可預期的副作用。 - 對外部 API 回傳的資料 使用 索引簽名 +
unknown,再逐步窄化(type guard)以取得安全的屬性。 - 利用
Record<K, V>替代手寫的[key: K]: V,語意更清晰。 - 在大型專案中分層管理型別:
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 的世界裡寫出更安全、更有彈性的程式!