TypeScript 類別(Classes) – readonly 屬性
簡介
在日常的前端與 Node.js 專案中,資料的不可變性(immutability)是提升程式碼安全性與可維護性的關鍵之一。TypeScript 為了在編譯階段就捕捉到不小心改寫資料的錯誤,提供了 readonly 修飾子,讓開發者可以在類別(class)裡聲明「只能在建構時寫入、之後不可變更」的屬性。
readonly 不僅能防止意外的狀態變更,還能在 IDE 中提供更好的自動完成與提示,讓程式碼的意圖更清晰。本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整呈現 readonly 在 TypeScript 類別中的用法,協助 初學者 與 中級開發者 快速上手並在實務上正確運用。
核心概念
1. readonly 的基本語法
readonly 是 TypeScript 的 屬性修飾子,只能用在類別成員(屬性)上。其語法與 public、private、protected 類似,只是語意上表示「此屬性只能在宣告或建構函式(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[] 仍允許 push、splice 等方法。 |
改用 ReadonlyArray<string>,或將陣列包在 Object.freeze 中。 |
最佳實踐
- 盡量在模型層使用
readonly:像是資料庫主鍵、API 回傳的唯一識別碼等,應該在類別層面標記為只讀。 - 配合
Readonly<T>形成深層只讀:對於設定物件、JSON 解析結果等,使用Readonly<T>防止意外變更。 - 建構參數屬性與
readonly結合:簡化程式碼,同時保證不可變。 - 提供 getter 而非公開屬性:若需要執行時保護,可將屬性設為
private readonly,僅暴露get存取子。 - 在測試與程式碼審查時檢查只讀意圖:確保所有不應變更的欄位皆已加上
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 程式碼。祝你在開發旅程中,藉由只讀屬性打造出更堅固的程式結構!