本文 AI 產出,尚未審核

TypeScript 類別(Classes) – readonly 屬性

簡介

在日常的前端與 Node.js 專案中,資料的不可變性(immutability)是提升程式碼安全性與可維護性的關鍵之一。TypeScript 為了在編譯階段就捕捉到不小心改寫資料的錯誤,提供了 readonly 修飾子,讓開發者可以在類別(class)裡聲明「只能在建構時寫入、之後不可變更」的屬性。

readonly 不僅能防止意外的狀態變更,還能在 IDE 中提供更好的自動完成與提示,讓程式碼的意圖更清晰。本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整呈現 readonly 在 TypeScript 類別中的用法,協助 初學者中級開發者 快速上手並在實務上正確運用。


核心概念

1. readonly 的基本語法

readonly 是 TypeScript 的 屬性修飾子,只能用在類別成員(屬性)上。其語法與 publicprivateprotected 類似,只是語意上表示「此屬性只能在宣告或建構函式(constructor)中被賦值」。

class User {
  readonly id: number;      // 只能在建構時寫入
  name: string;             // 可隨時變更

  constructor(id: number, name: string) {
    this.id = id;            // 合法:建構階段賦值
    this.name = name;
  }
}

注意readonly 只在 編譯階段 生效,編譯後的 JavaScript 仍然可以透過直接存取屬性修改值(除非使用 Object.freeze 等手段)。因此 readonly 的目的在於 提供靜態檢查,而非真正的執行時防護。

2. readonly 與建構參數屬性(Parameter Properties)

TypeScript 允許在建構子參數前直接加上修飾子,省去先宣告再賦值的程式碼。結合 readonly,可以一次完成屬性宣告與初始化。

class Product {
  // 直接在參數前加 readonly,即宣告且只能在建構時寫入
  constructor(public readonly sku: string, private price: number) {}
}

const p = new Product('A001', 199);
console.log(p.sku);   // A001
// p.sku = 'B002';    // 編譯錯誤:Cannot assign to 'sku' because it is a read-only property.

3. readonly 與介面(Interface)

readonly 同樣適用於介面,讓實作類別必須遵守只讀屬性的合約。

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

class ImmutablePoint implements Point {
  constructor(public readonly x: number, public readonly y: number) {}
}

4. readonly 與陣列、物件型別的只讀(ReadOnlyArray、Readonly<T>)

在類別屬性若是集合型別,僅把屬性本身標記為 readonly 並不會讓集合內容不可變。此時可以搭配 TypeScript 提供的 只讀集合型別

class Playlist {
  // 只讀的陣列,外部無法使用 push/pop 等可變方法
  readonly tracks: ReadonlyArray<string>;

  constructor(tracks: string[]) {
    this.tracks = tracks; // 直接指派,編譯器會推斷為 ReadonlyArray<string>
  }
}

若要讓物件內部屬性也全部只讀,則可使用 Readonly<T>

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

class Service {
  readonly config: Readonly<Config>;

  constructor(cfg: Config) {
    this.config = cfg; // 設定後外部無法變更 config 的任意屬性
  }
}

5. readonly 與繼承

子類別可以 重新宣告 父類別的 readonly 屬性,但必須保持只讀的性質,且不能把只讀屬性改為可寫。

class Base {
  readonly version: string = '1.0';
}

class Derived extends Base {
  // 正確:保持 readonly
  readonly version: string = '2.0';
  // error: Property 'version' in type 'Derived' is not assignable to the same property in base type 'Base'.
}

程式碼範例

以下提供 五個實用範例,說明 readonly 在不同情境下的使用方式與注意點。

範例 1:模型層的不可變 ID

class Order {
  // 訂單編號一旦產生就不應該變更
  public readonly orderId: string;
  public status: 'pending' | 'paid' | 'shipped';

  constructor(orderId: string) {
    this.orderId = orderId;
    this.status = 'pending';
  }

  // 允許變更狀態,但不能變更 orderId
  pay() {
    this.status = 'paid';
  }
}

說明orderId 只在建構時設定,之後任何嘗試改寫的程式碼都會在編譯階段被捕捉。

範例 2:使用建構參數屬性簡化程式

class UserProfile {
  // name 可寫、email 只讀
  constructor(public name: string, public readonly email: string) {}
}

const u = new UserProfile('Alice', 'alice@example.com');
u.name = 'Alicia';          // 合法
// u.email = 'new@ex.com'; // 編譯錯誤

說明:只需要一行建構子就完成屬性的宣告與初始化,減少樣板程式碼。

範例 3:只讀集合與深層只讀

type Settings = {
  theme: 'light' | 'dark';
  language: string;
};

class AppConfig {
  // 整個設定物件不可變
  readonly options: Readonly<Settings>;

  // 只讀陣列,外部無法 push/pop
  readonly supportedLocales: ReadonlyArray<string>;

  constructor(opts: Settings, locales: string[]) {
    this.options = opts;
    this.supportedLocales = locales;
  }
}

const cfg = new AppConfig({ theme: 'dark', language: 'zh-TW' }, ['en', 'zh-TW']);
console.log(cfg.options.theme); // dark
// cfg.options.theme = 'light'; // 編譯錯誤
// cfg.supportedLocales.push('ja'); // 編譯錯誤

說明Readonly<T>ReadonlyArray<T> 讓集合內部也保持不可變,避免意外的副作用。

範例 4:介面與類別的只讀合約

interface ImmutableVector {
  readonly x: number;
  readonly y: number;
}

class Vector2D implements ImmutableVector {
  constructor(public readonly x: number, public readonly y: number) {}
}

const v = new Vector2D(10, 20);
// v.x = 5; // 編譯錯誤

說明:介面中定義的只讀屬性會被實作類別強制遵守,形成明確的不可變合約。

範例 5:繼承時保持只讀

class Animal {
  readonly species: string;
  constructor(species: string) {
    this.species = species;
  }
}

class Dog extends Animal {
  // 必須同樣宣告為 readonly,否則會錯誤
  readonly species: string = 'Canis lupus familiaris';
  constructor() {
    super('Canis lupus familiaris');
  }
}

說明:子類別若要覆寫父類別的只讀屬性,仍需保持 readonly,確保不可變性在繼承鏈上不被破壞。


常見陷阱與最佳實踐

陷阱 說明 解決方式
只在編譯階段檢查 readonly 只在 TypeScript 編譯時有效,執行時仍可被修改。 若需要執行時保護,可使用 Object.freeze 或將屬性設為 私有,只提供 getter。
只讀屬性指向可變物件 readonly obj: MyObj 只能防止重新指派 obj,但 obj 內部屬性仍可變。 使用 Readonly<T> 包裝深層物件,或在建構子中 Object.freeze
建構參數屬性忘記加 readonly 在參數前加 public 而非 public readonly,會導致屬性可被外部改寫。 建議在參數屬性使用時,明確寫出 readonly,避免遺漏。
繼承時意外改寫只讀 子類別若直接宣告同名屬性且未加 readonly,編譯會報錯。 繼承時保持相同的修飾子,或使用 getter 只讀屬性。
只讀陣列仍能呼叫變更方法 readonly arr: string[] 仍允許 pushsplice 等方法。 改用 ReadonlyArray<string>,或將陣列包在 Object.freeze 中。

最佳實踐

  1. 盡量在模型層使用 readonly:像是資料庫主鍵、API 回傳的唯一識別碼等,應該在類別層面標記為只讀。
  2. 配合 Readonly<T> 形成深層只讀:對於設定物件、JSON 解析結果等,使用 Readonly<T> 防止意外變更。
  3. 建構參數屬性與 readonly 結合:簡化程式碼,同時保證不可變。
  4. 提供 getter 而非公開屬性:若需要執行時保護,可將屬性設為 private readonly,僅暴露 get 存取子。
  5. 在測試與程式碼審查時檢查只讀意圖:確保所有不應變更的欄位皆已加上 readonly,減少 bug 風險。

實際應用場景

場景 為何使用 readonly 範例
資料庫實體(Entity) 主鍵、建立時間等欄位一旦寫入就不應變更 class User { public readonly id: number; public readonly createdAt: Date; }
API 回傳的 DTO 防止在前端不小心改寫伺服器回傳的資料 interface ProductDTO { readonly sku: string; readonly price: number; }
全域設定(Config) 應用啟動後設定不允許動態改變,避免不一致狀態 class AppConfig { readonly env: Readonly<{ apiUrl: string; debug: boolean }>; }
事件物件(Event Payload) 事件處理器只需要讀取資料,不應改寫 interface DragEventPayload { readonly x: number; readonly y: number; }
Immutable Data Structure 使用 Redux、NgRx 等狀態管理時,資料結構必須保持不可變 type State = Readonly<{ counter: number; items: ReadonlyArray<string> }>;

透過上述場景,我們可以看到 readonly 不只是語法糖,而是 設計意圖的明確宣告,讓團隊成員與編譯器共同維護資料的不可變性。


總結

readonly 是 TypeScript 為類別屬性提供的 靜態不可變保證,配合建構參數屬性、Readonly<T>ReadonlyArray<T>,可以在編譯階段即捕捉到不當的狀態變更,提升程式碼的安全性與可維護性。使用時要注意:

  • readonly 只在編譯時檢查,若需要執行時保護,請結合 private、getter 或 Object.freeze
  • 只讀屬性指向的物件仍可能可變,使用 Readonly<T> 形成深層只讀。
  • 在繼承與介面實作中,保持相同的只讀修飾子,避免破壞不可變合約。

掌握這些概念後,你就能在 模型層、設定檔、API DTO 等關鍵位置,正確且有效地運用 readonly,寫出更可靠、更易於維護的 TypeScript 程式碼。祝你在開發旅程中,藉由只讀屬性打造出更堅固的程式結構!