本文 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等會改變長度或內容的方法。- 這在 Redux、Immutable.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 會把字面量收窄,可能導致 過度嚴格 的型別推斷。 |
僅在需要固定值且不會再變動的情境下使用,否則保留一般型別。 |
陣列的 readonly 與 push、splice |
只讀陣列根本沒有 push、splice 等方法,編譯器會直接報錯。 |
若需要「暫時可變」再轉成只讀,使用 let tmp = [...readonlyArr]; tmp.push(4); readonlyArr = tmp as readonly number[]; |
最佳實踐
- 預設使用
readonly:在介面或類別中,對於不會在生命週期內改變的屬性,預設加上readonly,讓意圖更明確。 - 使用
as const定義常數表:列舉 API 路徑、狀態字串、顏色代碼等時,配合as const可同時得到 字面量型別 與 只讀保護。 - 深層不可變時採用
DeepReadonly:在 Redux、MobX 等狀態管理工具中,建議使用遞迴只讀型別,避免意外突變。 - 結合 lint 規則:開啟
@typescript-eslint/readonly-type、prefer-const等規則,強制在可能的情況下使用readonly。 - 文件說明:在程式碼註解或 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時,寫出更安全、更易維護的程式!