本文 AI 產出,尚未審核

TypeScript 物件與介面 – readonly 屬性


簡介

在大型前端或 Node.js 專案中,資料的不可變性 是維護程式正確性與降低 Bug 風險的關鍵。
TypeScript 提供了 readonly 關鍵字,讓開發者可以在編譯階段就宣告「此屬性只能在建構時寫入,之後不可再被修改」。

使用 readonly 不僅能讓 IDE 給予即時警告,還能在團隊協作時明確傳達「此值是常數」的意圖,避免不小心的突變 (mutation)。
本篇文章將從語法、實作、常見陷阱到實務應用,完整說明 readonly 屬性 在 TypeScript 中的使用方式與最佳實踐。


核心概念

1. 基本語法:在介面或類別中宣告 readonly

interface User {
  readonly id: number;      // 只能在物件建立時指定
  name: string;             // 可隨時修改
}

const alice: User = { id: 1, name: "Alice" };
alice.name = "Alicia";      // ✅ 正常
// alice.id = 2;            // ❌ 編譯錯誤:Cannot assign to 'id' because it is a read-only property.
  • readonly 只限制直接寫入屬性,不會阻止透過方法間接改變(例如 Object.assign... 展開)。
  • 若屬性同時是 可選的 (?),仍可在建立物件時賦值或保持 undefined,之後同樣不可變更。

2. readonly 與類別 (class) 成員

class Point {
  readonly x: number;
  readonly y: number;

  constructor(x: number, y: number) {
    this.x = x;   // 建構子內仍可寫入
    this.y = y;
  }

  // 以下方法若嘗試改變 x、y 會編譯錯誤
  // move(dx: number) { this.x += dx; } // ❌
}
  • 建構子是唯一可以賦值的時機,之後屬性即成為不可變的「常數」。
  • 若屬性在宣告時就給定預設值,也可省略建構子:
class Config {
  readonly version = "1.0.0";   // 直接在欄位上初始化
}

3. readonly 陣列與元組

// 一般可變陣列
let mutable: number[] = [1, 2, 3];
mutable[0] = 10;   // ✅

 // 只讀陣列
let readonlyArr: readonly number[] = [1, 2, 3];
// readonlyArr[0] = 10; // ❌ 編譯錯誤

// 只讀元組
type Point3D = readonly [number, number, number];
const origin: Point3D = [0, 0, 0];
// origin[0] = 1; // ❌
  • readonly 陣列只能讀取,無法使用 push/pop/splice 等會改變長度或內容的方法。
  • 這在 ReduxImmutable.js 等需要「不可變資料」的框架中非常實用。

4. Readonly<T> 工具型別

TypeScript 內建的 泛型工具型別 Readonly<T> 能一次把物件的所有屬性轉為只讀:

interface Settings {
  theme: string;
  language: string;
  debug: boolean;
}

type ImmutableSettings = Readonly<Settings>;

const cfg: ImmutableSettings = {
  theme: "dark",
  language: "zh-TW",
  debug: false,
};
// cfg.debug = true; // ❌

注意Readonly<T> 只會把第一層屬性設為只讀,若屬性本身是物件,仍是淺層只讀

5. as const 斷言產生的只讀物件

const COLORS = {
  red: "#ff0000",
  green: "#00ff00",
  blue: "#0000ff",
} as const;   // 整個物件與屬性皆變成 readonly

// COLORS.red = "#f00"; // ❌
type ColorKey = keyof typeof COLORS; // "red" | "green" | "blue"
  • as const 同時會把字面量的型別「收窄」為具體值(例如 "red" 而不是 string),非常適合 列舉常數設定檔 等情境。

6. 使用映射型別 (Mapped Types) 產生自訂的只讀結構

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

interface Nested {
  name: string;
  meta: {
    created: Date;
    tags: string[];
  };
}

type ImmutableNested = DeepReadonly<Nested>;

const data: ImmutableNested = {
  name: "Bob",
  meta: {
    created: new Date(),
    tags: ["admin", "user"],
  },
};
// data.meta.tags.push("new"); // ❌ 因為 tags 本身已被遞迴設為 readonly
  • 透過 映射型別 可以自行實作 深層只讀,解決 Readonly<T> 只做淺層的限制。

常見陷阱與最佳實踐

陷阱 說明 建議的作法
淺層只讀 readonly 只保護第一層屬性,內部物件仍可被突變。 若需要深層不可變,使用遞迴映射型別 DeepReadonly<T>Object.freeze(配合 lint)。
Object.freeze 混用 Object.freeze 在執行時凍結物件,readonly 只在編譯時檢查。兩者不會互相影響。 在需要 執行期 防止突變時,結合 Object.freeze;在 型別層面 仍保留 readonly
可選屬性 ? + readonly readonly foo?: string 允許 undefined,但若在建立後賦值仍會錯誤。 確認在建立物件時已提供值,或使用 預設值 來避免未賦值的情況。
使用 as const 產生的字面量類型 as const 會把字面量收窄,可能導致 過度嚴格 的型別推斷。 僅在需要固定值且不會再變動的情境下使用,否則保留一般型別。
陣列的 readonlypushsplice 只讀陣列根本沒有 pushsplice 等方法,編譯器會直接報錯。 若需要「暫時可變」再轉成只讀,使用 let tmp = [...readonlyArr]; tmp.push(4); readonlyArr = tmp as readonly number[];

最佳實踐

  1. 預設使用 readonly:在介面或類別中,對於不會在生命週期內改變的屬性,預設加上 readonly,讓意圖更明確。
  2. 使用 as const 定義常數表:列舉 API 路徑、狀態字串、顏色代碼等時,配合 as const 可同時得到 字面量型別只讀保護
  3. 深層不可變時採用 DeepReadonly:在 Redux、MobX 等狀態管理工具中,建議使用遞迴只讀型別,避免意外突變。
  4. 結合 lint 規則:開啟 @typescript-eslint/readonly-typeprefer-const 等規則,強制在可能的情況下使用 readonly
  5. 文件說明:在程式碼註解或 API 文件中標註「此屬性為只讀」,讓非 TypeScript 使用者也能了解不可變的限制。

實際應用場景

1. Redux 狀態樹

interface AppState {
  readonly user: {
    readonly id: number;
    readonly name: string;
  };
  readonly settings: Readonly<{
    theme: string;
    language: string;
  }>;
}
  • 每一次 dispatch 都會產生 全新物件,因此所有屬性建議都設為 readonly,讓 reducer 在編寫時不會意外改變舊狀態。

2. API 回傳模型

type ApiResponse<T> = Readonly<{
  data: T;
  status: number;
  error?: string;
}>;

function fetchUser(): Promise<ApiResponse<User>> {
  // ...
}
  • 透過 Readonly 包裝回傳型別,保證呼叫端不會直接改寫 API 回傳的資料,減少副作用。

3. 組件 Props(React)

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

const Button: React.FC<ButtonProps> = ({ label, disabled, onClick }) => (
  <button disabled={disabled} onClick={onClick}>
    {label}
  </button>
);
  • Props 本質上是只讀的,使用 readonly 可以讓 TypeScript 在父組件傳遞錯誤時即時提示。

4. 全域設定檔

export const ENV = {
  API_URL: "https://api.example.com",
  VERSION: "2.3.1",
} as const; // 完全不可變
  • 任何地方誤寫 ENV.API_URL = "foo" 都會在編譯階段被捕捉,避免環境變數被意外改寫。

5. 讀寫分離的資料結構

class Cache<T> {
  private _store: Map<string, T> = new Map();

  get(key: string): Readonly<T> | undefined {
    const value = this._store.get(key);
    return value ? (Object.freeze(value) as Readonly<T>) : undefined;
  }

  set(key: string, value: T): void {
    this._store.set(key, value);
  }
}
  • get 方法回傳 只讀版本,確保外部使用者無法直接修改快取內部的物件。

總結

  • readonly 是 TypeScript 編譯期保護資料不可變的核心工具,適用於介面、類別、陣列、元組與映射型別。
  • 淺層只讀 是預設行為,若需要 深層不可變,可自行實作遞迴映射型別 DeepReadonly<T>,或在執行期配合 Object.freeze
  • Redux、React Props、API 回傳模型、全域設定 等常見情境中,將不會再變動的屬性標註為 readonly,能顯著提升程式碼的可讀性與安全性。
  • 結合 lint 規則與適當的註解,讓團隊在開發過程中自然遵守不可變的原則,減少突變所帶來的錯誤與除錯成本。

把「只讀」寫在型別上,而不是寫在程式碼的每一行,這是 TypeScript 讓大型專案保持可維護性的關鍵技巧。祝你在實作 readonly 時,寫出更安全、更易維護的程式!