TypeScript 物件型別 (Object Types)

在 TypeScript 中,物件型別用來描述物件的結構,包括屬性的名稱和型別。這是 TypeScript 型別系統的核心概念之一。

物件型別標註

內聯物件型別

直接在變數後面標註物件的結構:

let user: { name: string; age: number } = {
  name: '小明',
  age: 25,
};

// 存取屬性
console.log(user.name); // 小明
console.log(user.age); // 25

函式參數的物件型別

function printUser(user: { name: string; age: number }): void {
  console.log(`${user.name}, ${user.age} 歲`);
}

printUser({ name: '小明', age: 25 });

可選屬性 (Optional Properties)

使用 ? 標記可選屬性:

let user: {
  name: string;
  age: number;
  email?: string; // 可選屬性
} = {
  name: '小明',
  age: 25,
  // email 可以省略
};

// 存取可選屬性時可能是 undefined
function printEmail(user: { email?: string }) {
  if (user.email) {
    console.log(user.email);
  } else {
    console.log('沒有提供 email');
  }
}

唯讀屬性 (Readonly Properties)

使用 readonly 關鍵字標記唯讀屬性:

let point: {
  readonly x: number;
  readonly y: number;
} = { x: 10, y: 20 };

console.log(point.x); // 10
// point.x = 30;       // 錯誤:無法指派給 'x',因為它是唯讀屬性
readonly 只會在編譯時期檢查,不會影響執行時的行為。

索引簽名 (Index Signatures)

當物件可以有動態的屬性名稱時,使用索引簽名:

// 字串索引簽名
let dictionary: { [key: string]: string } = {
  apple: '蘋果',
  banana: '香蕉',
  orange: '橘子',
};

dictionary['grape'] = '葡萄'; // OK
console.log(dictionary['apple']); // 蘋果

// 數字索引簽名
let list: { [index: number]: string } = ['a', 'b', 'c'];
console.log(list[0]); // a

結合固定屬性和索引簽名

interface Config {
  name: string;
  version: number;
  [key: string]: string | number; // 其他屬性可以是 string 或 number
}

let config: Config = {
  name: 'my-app',
  version: 1,
  author: '小明',
  port: 3000,
};
索引簽名的值型別必須能包含所有固定屬性的型別。

物件型別的結構化型別 (Structural Typing)

TypeScript 使用結構化型別系統,只要結構相符就視為相同型別:

interface Point {
  x: number;
  y: number;
}

function printPoint(point: Point) {
  console.log(`(${point.x}, ${point.y})`);
}

// 不需要明確宣告實作 Point
let myPoint = { x: 10, y: 20, z: 30 }; // 多了 z 屬性
printPoint(myPoint); // OK,因為有 x 和 y

// 但直接傳入字面量時會檢查多餘屬性
// printPoint({ x: 10, y: 20, z: 30 });  // 錯誤:多餘的屬性 'z'

交集型別 (Intersection Types)

使用 & 組合多個物件型別:

type Named = { name: string };
type Aged = { age: number };
type Person = Named & Aged;

let person: Person = {
  name: '小明',
  age: 25,
};

巢狀物件型別

interface Address {
  city: string;
  street: string;
  zipCode: string;
}

interface User {
  name: string;
  age: number;
  address: Address;
}

let user: User = {
  name: '小明',
  age: 25,
  address: {
    city: '台北市',
    street: '信義路',
    zipCode: '110',
  },
};

console.log(user.address.city); // 台北市

Record 工具型別

Record<K, V> 是建立物件型別的快捷方式:

// 相當於 { [key: string]: number }
type StringToNumber = Record<string, number>;

let scores: StringToNumber = {
  math: 90,
  english: 85,
  science: 92,
};

// 使用聯合型別作為 key
type Fruit = 'apple' | 'banana' | 'orange';
type FruitInventory = Record<Fruit, number>;

let inventory: FruitInventory = {
  apple: 10,
  banana: 15,
  orange: 8,
};

物件的解構與型別

// 解構時標註型別
function printUser({ name, age }: { name: string; age: number }) {
  console.log(`${name}, ${age} 歲`);
}

// 解構並重新命名
function printCoordinate({ x: xPos, y: yPos }: { x: number; y: number }) {
  console.log(`x 座標: ${xPos}, y 座標: ${yPos}`);
}

// 解構時設定預設值
function createUser({ name, age = 18 }: { name: string; age?: number }) {
  return { name, age };
}

多餘屬性檢查 (Excess Property Checks)

直接傳入物件字面量時,TypeScript 會檢查多餘的屬性:

interface Point {
  x: number;
  y: number;
}

// 直接傳入字面量會報錯
// let p: Point = { x: 10, y: 20, z: 30 };  // 錯誤

// 透過變數則不會
let obj = { x: 10, y: 20, z: 30 };
let p: Point = obj; // OK

// 使用型別斷言
let p2 = { x: 10, y: 20, z: 30 } as Point; // OK

Object vs object vs

這三個型別有不同的含義:

// Object:幾乎所有值 (除了 null 和 undefined)
let a: Object = 'hello'; // OK
let b: Object = 123; // OK
let c: Object = {}; // OK

// object:非原始型別的值
let d: object = {}; // OK
let e: object = []; // OK
// let f: object = "hello";  // 錯誤

// {}:空物件型別,可以接受任何值 (除了 null 和 undefined)
let g: {} = 'hello'; // OK
let h: {} = 123; // OK
// 但不能存取任何屬性
// g.length;  // 錯誤
建議避免使用 Objectobject{},改用更具體的介面或型別。

實用範例

API 回應物件

interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: string;
  timestamp: number;
}

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

function handleResponse(response: ApiResponse<User>) {
  if (response.success) {
    console.log(response.data.name);
  } else {
    console.error(response.error);
  }
}

設定物件

interface AppConfig {
  readonly appName: string;
  version: string;
  settings: {
    theme: 'light' | 'dark';
    language: string;
    notifications: boolean;
  };
  features?: {
    [key: string]: boolean;
  };
}

const config: AppConfig = {
  appName: 'My App',
  version: '1.0.0',
  settings: {
    theme: 'dark',
    language: 'zh-TW',
    notifications: true,
  },
  features: {
    newDashboard: true,
    experimentalFeature: false,
  },
};