本文 AI 產出,尚未審核

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. 範例二:結合 PartialReadonly

有時候我們想要部分更新物件,同時保證已存在的屬性仍是唯讀。可以先使用 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 constReadonly<T> 重複 同時使用會造成型別過度限制,導致不必要的型別衝突。 只選擇其中一種方式,根據需求決定是「值層面的唯讀」或「型別層面的唯讀」。
與映射型別結合時的 keyof 失效 若原型別使用了 索引簽名[key: string]: any),Readonly<T> 仍會套用 readonly,但在某些情況下會失去索引的可寫性。 確認需求後,手動寫出更精確的映射型別或使用條件型別過濾索引簽名。

最佳實踐

  1. 在公共 API 中盡量使用 Readonly<T>:讓使用者無法直接改變傳入的物件。
  2. 結合 as const 宣告常量:對於字面量常數,使用 as const 讓值本身也變成唯一路徑。
  3. 在大型設定或狀態物件上使用 DeepReadonly:避免深層屬性被意外變更。
  4. 保持型別一致性:如果函式接受 Readonly<T>,則返回值也應維持相同的唯讀型別,形成 不可變的資料流
  5. 使用 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 組件只能讀取資料,若需要變更,必須透過 dispatchmutation 的方式,維持資料流的單向性。

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 的世界裡寫出更安全、更健壯的程式碼!