本文 AI 產出,尚未審核

TypeScript – 陣列與集合(Arrays & Collections)

主題:ReadonlyArrayreadonly 修飾


簡介

在日常的前端開發中,陣列是最常使用的資料結構之一。雖然 JavaScript 允許我們隨意修改陣列的內容與長度,但在大型專案或多人協作時,不小心改變了原本應該保持不變的資料 常會導致難以追蹤的錯誤。
TypeScript 為了提升程式的可預測性與安全性,提供了兩種「唯讀」的概念:

  1. ReadonlyArray<T> – 針對整個陣列的唯讀型別。
  2. readonly 修飾符 – 可用於陣列、元組 (tuple) 以及物件屬性,讓對應的資料在編譯階段即被視為不可變。

掌握這兩者的用法,不僅可以防止意外的資料變更,還能讓 IDE 更有效地提示錯誤,提升開發效率與程式碼品質。


核心概念

1. ReadonlyArray<T> 基本使用

ReadonlyArray<T> 與一般的 Array<T> 差別在於 所有會改變陣列內容的方法都被移除(例如 pushpopsplice 等),只能使用讀取型方法(如 mapfilterfind 等)。

// 一般陣列
let mutable: number[] = [1, 2, 3];
mutable.push(4);          // ✅ 可執行

// 只讀陣列
let readonly: ReadonlyArray<number> = [1, 2, 3];
// readonly.push(4);      // ❌ 編譯錯誤:Property 'push' does not exist on type 'readonly number[]'.

重點ReadonlyArray<T> 其實在編譯後仍會產生普通的 JavaScript 陣列,只是 TypeScript 編譯器在開發階段阻止修改操作。

2. readonly 修飾符(陣列)

從 TypeScript 3.4 開始,readonly 可以直接寫在陣列型別前,語法更簡潔:

// 使用 readonly 關鍵字
let nums: readonly number[] = [10, 20, 30];
// nums[0] = 99;               // ❌ 編譯錯誤:Cannot assign to '0' because it is a read-only property.

readonly number[]ReadonlyArray<number> 完全等價,只是前者是語法糖,較常在函式參數或型別別名中使用。

type Point = readonly [number, number]; // 只讀元組
const origin: Point = [0, 0];
// origin[0] = 5;               // ❌ 錯誤

3. readonly 修飾符(物件屬性)

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.

4. 只讀與可變的混合型別

有時候我們需要部份只讀、部份可寫的陣列,例如只想保護索引位置但允許 push。可以透過 交叉類型映射型別 來達成:

type MutablePush<T> = T & { push(...items: T[number][]): number };
let mixed: MutablePush<readonly number[]> = [1, 2, 3] as any;
mixed[0] = 99;           // ✅ 仍可修改元素
mixed.push(4);           // ✅ 仍可使用 push

實務建議:除非真的需要,盡量避免這種「半只讀」的做法,會讓程式的意圖變得模糊。

5. 只讀陣列的深層不可變(DeepReadonly)

readonly 只能保護第一層結構,若陣列內的元素本身是物件,仍然可以被修改。若想要 深層不可變,可以自行定義遞迴型別:

type DeepReadonly<T> = T extends Function
  ? T
  : T extends Array<infer U>
    ? ReadonlyArray<DeepReadonly<U>>
    : T extends object
      ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
      : T;

interface Todo {
  title: string;
  tags: string[];
}
const todo: DeepReadonly<Todo> = {
  title: "寫教學文章",
  tags: ["typescript", "readonly"]
};

// todo.title = "改標題";   // ❌ 錯誤
// todo.tags.push("new"); // ❌ 錯誤

此型別在大型狀態管理(如 Redux)或不可變資料庫(Immutable.js)時特別有用。


程式碼範例

以下示範 5 個實務上常見的使用情境,並在每段程式碼後加入說明。

範例 1:函式參數使用 readonly 陣列

function sum(values: readonly number[]): number {
  // 只能讀取,不能 push/pop
  return values.reduce((acc, cur) => acc + cur, 0);
}

const data = [1, 2, 3, 4];
console.log(sum(data));   // 10
// sum([...data, 5]);      // 仍可傳入新的陣列,但 sum 本身不會改變傳入的陣列

說明:把參數宣告為 readonly,保證函式內不會意外改變傳入的陣列,提升 API 的可預測性。

範例 2:只讀元組作為函式回傳值

function splitName(fullName: string): readonly [string, string] {
  const [first, last] = fullName.split(" ");
  return [first, last] as const; // as const 讓陣列變成 readonly tuple
}

const [firstName, lastName] = splitName("John Doe");
console.log(firstName, lastName); // John Doe
// firstName = "Jane";            // ❌ 錯誤:firstName 為常數,且是只讀元組的成員

說明:使用 readonly 元組可以明確表示「此回傳值的每個元素皆不可變」,配合 as const 讓編譯器自動推斷。

範例 3:DeepReadonly 用於 Redux 狀態

// 假設我們有一個 Redux state
interface AppState {
  user: {
    id: number;
    name: string;
  };
  todos: { id: number; text: string; completed: boolean }[];
}

// 使用 DeepReadonly 讓 reducer 的參數不可變
type ReadonlyState = DeepReadonly<AppState>;

function reducer(state: ReadonlyState, action: any): ReadonlyState {
  // state.user.name = "Bob"; // ❌ 編譯錯誤,保護深層結構
  switch (action.type) {
    case "ADD_TODO":
      // 下面的寫法會觸發錯誤,必須回傳全新物件
      // return { ...state, todos: [...state.todos, action.payload] };
      return { ...state, todos: [...state.todos, action.payload] } as ReadonlyState;
    default:
      return state;
  }
}

說明:透過 DeepReadonly,開發者在 reducer 中不會不小心直接修改 state,遵守 Redux 的不可變原則。

範例 4:只讀屬性搭配類別

class Config {
  // 只讀屬性只能在建構子裡賦值
  readonly apiEndpoint: string;
  readonly retryCount: number = 3; // 可直接初始化

  constructor(endpoint: string) {
    this.apiEndpoint = endpoint;
  }
}

const cfg = new Config("https://api.example.com");
// cfg.apiEndpoint = "https://new.com"; // ❌ 錯誤
console.log(cfg.apiEndpoint); // https://api.example.com

說明readonly 在類別中非常適合描述「只在建構子或初始化時設定」的設定值,避免在程式執行期間被誤改。

範例 5:利用 readonly 防止陣列被外部修改

function createCache<T>(items: T[]): ReadonlyArray<T> {
  // 把傳入的陣列直接返回只讀版本
  return items;
}

const cache = createCache([1, 2, 3]);
cache[0] = 99;   // ❌ 錯誤
// cache.push(4); // ❌ 錯誤

說明:當你想要提供「只讀」的資料給其他模組時,直接回傳 ReadonlyArray 能有效防止外部程式碼改變內部狀態。


常見陷阱與最佳實踐

陷阱 可能的問題 解決方式 / 最佳實踐
只讀不等於深層不可變 陣列裡的物件仍可被改寫,例如 readonly Person[] 中的 Person 屬性仍可變。 若需要深層不可變,使用遞迴型別 DeepReadonly<T>,或在資料流上使用 immutable 資料結構(如 immerImmutable.js)。
as const 用錯位置 as const 只能在字面量(literal)上使用,若放在變數或函式回傳值外層會失效。 確保 as const 緊貼在字面量或陣列/物件前,例如 return [a, b] as const;
混用 readonly 與可變方法 交叉類型 MutablePush<readonly number[]> 會讓型別變得模糊,導致 IDE 提示失效。 盡量保持「全只讀」或「全可變」的設計,避免半只讀的複雜型別。
忘記在函式參數加 readonly 函式內不小心使用 pushsplice 改變傳入的陣列,造成呼叫端的資料被意外改動。 在任何接受陣列的公共 API 中,都應該使用 readonly(或 ReadonlyArray)作為參數型別。
readonly 用於類別的 static 成員 static readonly 僅保護屬性本身,若屬性是陣列仍可被修改。 同樣需要把屬性型別設為 ReadonlyArray<T>,或在建構子中 Object.freeze

最佳實踐小結

  1. 盡可能在 API 邊界使用只讀型別(函式參數、回傳值)。
  2. 若陣列內部是物件,考慮使用 DeepReadonly 或 immutable 庫。
  3. 使用 as const 讓字面量自動變成只讀,減少手動加 readonly 的繁瑣。
  4. 在類別與介面中,將不會變動的屬性標記為 readonly,讓意圖更清晰。
  5. 配合 lint 規則(如 @typescript-eslint/prefer-readonly,自動提醒可改為只讀的情況。

實際應用場景

1. 前端 UI 共享資料

在 React、Vue 或 Angular 中,常會把父層的狀態(props、store)傳給子元件。如果子元件意外修改了父層的陣列,會造成 UI 不一致。把 props 定義為 readonly 陣列可避免此類問題:

// React 範例
type ItemListProps = {
  items: readonly string[];
};

function ItemList({ items }: ItemListProps) {
  // items.push('new'); // ❌ 編譯錯誤
  return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
}

2. API 回傳資料的型別安全

後端回傳的 JSON 陣列大多是「只讀」的資料,前端在接收後不應該再直接改寫。使用 ReadonlyArray 作為型別,可在編譯階段捕捉不當寫入:

interface ApiResponse {
  users: ReadonlyArray<{ id: number; name: string }>;
}

// fetch 後的使用
async function loadUsers(): Promise<ApiResponse> {
  const res = await fetch("/api/users");
  return await res.json(); // TypeScript 會自動推斷為 ReadonlyArray
}

3. 狀態管理(Redux / NgRx)

如前面範例所示,Redux 要求狀態不可變。把整個 state 用 DeepReadonly 包起來,可在 reducer 中即時得到錯誤提示,避免不小心直接改變舊 state。

4. 公用函式庫(Utility Library)

開發通用的工具函式(例如 lodashramda 的 TypeScript 版本)時,應該把所有接受陣列的函式參數宣告為 readonly,確保函式本身不會產生副作用。


總結

  • ReadonlyArray<T>readonly T[] 為 TypeScript 提供的陣列唯讀型別,能在編譯階段阻止所有會改變陣列結構的方法。
  • readonly 修飾符 不僅適用於陣列,還能標記元組、物件屬性以及類別成員,使資料在被建立後保持不變。
  • 只讀僅保護第一層結構;若需要 深層不可變,可自行實作遞迴型別 DeepReadonly<T> 或使用第三方 immutable 庫。
  • 在實務開發中,將只讀型別作為 API 邊界、狀態管理、共享資料的防護盾,能大幅降低因意外修改資料而產生的 bug。
  • 避免半只讀的複雜型別,保持程式碼意圖清晰;同時配合 lint 及 IDE 的型別檢查,讓只讀的好處發揮最大效益。

透過本文的概念與範例,你已掌握在 TypeScript 中使用 ReadonlyArrayreadonly 的要領,未來在撰寫大型前端或全端應用時,記得善用這些型別工具,讓程式碼更安全、更易維護。祝開發順利! 🚀