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. 例外:any、unknown 與結構化型別
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 設計。 | noImplicitAny、strictFunctionTypes 讓多餘參數必須明確標記 (...args: any[])。 |
any 濫用 |
失去型別檢查的好處。 | 儘量改用 unknown,並在需要時使用 type guard。 |
| 介面合併意外衝突 | 多次宣告同名介面時,屬性衝突會合併,可能產生不預期的型別。 | 在大型專案中,使用 type 或 命名空間 來避免不必要的合併。 |
最佳實踐
- 以最小需求描述介面:只列出必須的屬性,讓物件更具彈性。
- 利用
readonly保護不變資料:尤其是從 API 取得的回應,避免意外改寫。 - 結合
Partial<T>、Pick<T,K>、Omit<T,K>產生變形型別,提升重用性。 - 啟用嚴格模式 (
strict: true):確保結構化型別的檢查不被寬鬆設定所削弱。 - 寫型別守衛(type guard):在處理
unknown、any或外部資料時,明確縮小型別範圍。
實際應用場景
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 共享
多個微服務使用相同的資料結構(如 User、Order),但各自維護自己的介面檔案。只要結構相同,就能在 TypeScript 中直接相互指派,避免重複定義。
3. 第三方函式庫的型別擴充
許多 npm 套件提供 宣告檔 (.d.ts);若需要加入自訂屬性,只要符合原始介面的結構即可,透過 模組擴充(module augmentation) 實現。
import "express";
declare module "express" {
interface Request {
user?: User; // 只要結構相符,即可在中介軟體中掛載
}
}
4. 動態表單與驗證
使用 Yup、Zod 等驗證函式庫時,會先產生一個「資料結構」的描述。透過結構化型別,我們可以把驗證結果直接映射成 TypeScript 型別,確保後續程式碼的安全。
總結
結構化型別是 TypeScript 與 JavaScript 之間最具力量的橋樑。它讓開發者只要關注 資料的形狀,就能在不受類別名稱限制的情況下,享受到靜態型別帶來的安全與自動完成支援。
- 概念:型別相容性基於屬性與方法的結構,而非名稱。
- 實作:介面、型別別名、交叉/聯合型別、函式型別皆遵循結構比較。
- 實務:在 UI 組件、API DTO、微服務、第三方套件擴充等場景中,結構化型別提供了彈性與可維護性。
- 陷阱:過寬的型別、可選屬性未檢查、
any濫用等,需要透過嚴格設定與型別守衛加以防範。
只要遵循 「描述最小需求」、「保護不可變資料」、「啟用嚴格模式」 三大原則,你就能在日常開發中,充分利用結構化型別的優勢,寫出更可靠、可維護的 TypeScript 程式碼。祝你寫程式愉快,型別安全!