TypeScript 教學:Utility Types – Readonly<T>
簡介
在大型前端或 Node.js 專案中,資料結構往往會在多個模組之間流轉。若不小心修改了本該保持不變的物件,容易造成不可預期的錯誤與難以追蹤的 bug。
TypeScript 提供的 Readonly<T> 是一個 工具型別(Utility Type),它可以將任意型別的所有屬性一次性設為唯讀(readonly),從而在編譯階段就阻止不小心的寫入操作。
透過 Readonly<T>,開發者能:
- 明確表達意圖:讓讀者一眼就知道這個物件不應被修改。
- 提升程式安全性:在編譯期間捕捉到意外的賦值,減少執行時錯誤。
- 簡化程式碼:不需要手動為每個屬性加上
readonly,一次搞定整個型別。
以下將一步步說明 Readonly<T> 的使用方式、實作原理、常見陷阱以及在實務專案中的應用案例。
核心概念
1. 基本語法
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
Readonly<T> 使用 映射型別(Mapped Types) 以及 索引存取類型(Indexed Access Types),將傳入的型別 T 的每個屬性 P 重新映射為 readonly,而屬性值的型別保持不變。
重點:
Readonly<T>只會把直接屬性設為唯讀,巢狀物件的屬性仍然是可變的,除非對巢狀型別也套用Readonly。
2. 為什麼不直接使用 as const?
as const 是 斷言(Assertion)語法,用於將字面量值轉為最窄的唯讀型別(如 readonly [1, 2] 或 readonly { a: 1 })。
它的適用範圍是具體值,而 Readonly<T> 則是型別層級的工具,適用於任何已宣告的介面或類別。
// as const 只能在宣告時使用
const config = {
host: "localhost",
port: 3000,
} as const; // config 的型別變成 readonly { readonly host: "localhost"; readonly port: 3000; }
// Readonly<T> 可以在任何地方套用
interface Config {
host: string;
port: number;
}
type ReadonlyConfig = Readonly<Config>;
3. 範例一:最簡單的唯讀介面
interface User {
id: number;
name: string;
}
// 直接套用 Readonly
type ReadonlyUser = Readonly<User>;
const alice: ReadonlyUser = { id: 1, name: "Alice" };
// ✅ 正常讀取
console.log(alice.name);
// ❌ 編譯錯誤:Cannot assign to 'name' because it is a read-only property.
alice.name = "Bob";
說明:編譯器會在嘗試寫入
name時拋出錯誤,避免資料被意外改寫。
4. 範例二:結合 Partial 與 Readonly
有時候我們想要部分更新物件,同時保證已存在的屬性仍是唯讀。可以先使用 Partial<T> 再套用 Readonly<T>:
type UpdatableUser = Readonly<Partial<User>>;
let update: UpdatableUser = { name: "Charlie" };
// ✅ 可以寫入(因為屬性本身是可選的且仍為 readonly)
update.name = "Dave"; // 編譯錯誤:readonly
// 正確的做法是重新指派整個物件
update = { id: 2 };
重點:
Partial<T>使屬性變為可選,Readonly<T>再把這些可選屬性設為唯讀,常用於API 請求的 payload。
5. 範例三:遞迴唯讀(DeepReadonly)
Readonly<T> 只會處理第一層屬性。若需要深層的唯讀,可以自行實作遞迴版的 DeepReadonly:
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
interface Settings {
theme: {
primary: string;
secondary: string;
};
version: number;
}
type ImmutableSettings = DeepReadonly<Settings>;
const cfg: ImmutableSettings = {
theme: { primary: "#ff0", secondary: "#0ff" },
version: 1,
};
// ❌ 錯誤:無法修改巢狀屬性
cfg.theme.primary = "#f00";
說明:透過條件型別 (
T[P] extends object ? …) 讓每一層的屬性都被遞迴套用DeepReadonly,在大型設定物件中特別有用。
6. 範例四:與函式結合的唯讀參數
function logUser(user: Readonly<User>) {
console.log(`User #${user.id}: ${user.name}`);
// user.id = 2; // 編譯錯誤,保護參數不被改寫
}
logUser({ id: 10, name: "Eve" });
將函式參數標記為 Readonly<T>,可以保證函式內不會意外改變傳入的物件,讓 API 更具可預測性。
7. 範例五:在 Redux / Zustand 等狀態管理庫中的應用
// 假設我們使用 Redux Toolkit
interface Todo {
id: number;
text: string;
completed: boolean;
}
// 把 state 定義為 readonly,避免 reducer 直接改寫
type TodoState = Readonly<{ todos: Todo[] }>;
const initialState: TodoState = {
todos: [
{ id: 1, text: "Learn TS", completed: false },
],
};
// 在 reducer 中使用 immer,仍能保持外部型別的 readonly
function todoReducer(state = initialState, action: any): TodoState {
switch (action.type) {
case "add":
return { ...state, todos: [...state.todos, action.payload] };
default:
return state;
}
}
重點:即使底層使用可變的資料結構(如 immer),外部型別仍能以
Readonly<T>告訴開發者「此狀態不應直接被改寫」。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 巢狀結構仍可變 | Readonly<T> 只對第一層屬性加 readonly。 |
若需要完整防護,使用自訂的 DeepReadonly<T>。 |
| 與可變 API 混用 | 把 Readonly<T> 物件傳給期待可變參數的函式會產生錯誤。 |
在呼叫前使用 展開運算子 ({ ...obj }) 建立可變的拷貝,或改寫函式簽名接受 Readonly<T>。 |
| 類別實例的唯讀屬性 | Readonly<T> 只影響型別,對類別的 實例屬性 仍需在宣告時加 readonly。 |
在類別內部直接使用 readonly 修飾符,或將類別型別包裝成 Readonly<InstanceType<typeof MyClass>>。 |
使用 as const 與 Readonly<T> 重複 |
同時使用會造成型別過度限制,導致不必要的型別衝突。 | 只選擇其中一種方式,根據需求決定是「值層面的唯讀」或「型別層面的唯讀」。 |
與映射型別結合時的 keyof 失效 |
若原型別使用了 索引簽名([key: string]: any),Readonly<T> 仍會套用 readonly,但在某些情況下會失去索引的可寫性。 |
確認需求後,手動寫出更精確的映射型別或使用條件型別過濾索引簽名。 |
最佳實踐
- 在公共 API 中盡量使用
Readonly<T>:讓使用者無法直接改變傳入的物件。 - 結合
as const宣告常量:對於字面量常數,使用as const讓值本身也變成唯一路徑。 - 在大型設定或狀態物件上使用
DeepReadonly:避免深層屬性被意外變更。 - 保持型別一致性:如果函式接受
Readonly<T>,則返回值也應維持相同的唯讀型別,形成 不可變的資料流。 - 使用 Lint 規則(如
eslint-plugin-unicorn):自動提醒不可變資料的最佳實踐。
實際應用場景
1. API 回傳資料的防護
interface ApiResponse {
data: User[];
meta: { total: number };
}
// 把從後端取得的資料立即轉為 readonly,避免在 UI 中誤改
async function fetchUsers(): Promise<Readonly<ApiResponse>> {
const res = await fetch("/api/users");
const json = await res.json();
return json as Readonly<ApiResponse>;
}
此做法確保 UI 組件只能讀取資料,若需要變更,必須透過 dispatch 或 mutation 的方式,維持資料流的單向性。
2. 共享的全域設定(Config)
// config.ts
export const APP_CONFIG = Object.freeze({
apiBase: "https://api.example.com",
timeout: 5000,
} as const);
// 其他模組使用
import { APP_CONFIG } from "./config";
function request(endpoint: string) {
// 編譯期保證 APP_CONFIG 不會被改寫
return fetch(`${APP_CONFIG.apiBase}/${endpoint}`, {
timeout: APP_CONFIG.timeout,
});
}
Object.freeze + as const+ Readonly<T> 多層保護,防止全域設定被意外改寫。
3. Redux Toolkit 的 State 定義
在 Redux Toolkit 中,官方已經將 state 視為 不可變,但 TypeScript 並不會自動加上 readonly。
手動加上 Readonly<T> 或 DeepReadonly<T> 能讓 IDE 在 reducer 內部提示錯誤,提升開發體驗。
總結
Readonly<T>是 TypeScript 提供的 映射型別,能一次把物件的所有屬性設為readonly,在編譯階段防止意外寫入。- 它只針對第一層屬性;若需要 深層唯讀,可以自行實作遞迴版
DeepReadonly<T>。 - 常見的使用情境包括 API 回傳資料防護、全域設定、狀態管理,以及 函式參數的不可變保證。
- 使用時要留意巢狀結構、類別實例、以及與可變 API 的兼容性;配合 Lint 規則與
as const能取得更好的開發體驗。
透過適時地使用 Readonly<T>,可以讓程式碼的意圖更清晰、錯誤更早被捕捉,進而提升專案的可維護性與可靠度。祝你在 TypeScript 的世界裡寫出更安全、更健壯的程式碼!