TypeScript – 陣列與集合(Arrays & Collections)
主題:ReadonlyArray 與 readonly 修飾
簡介
在日常的前端開發中,陣列是最常使用的資料結構之一。雖然 JavaScript 允許我們隨意修改陣列的內容與長度,但在大型專案或多人協作時,不小心改變了原本應該保持不變的資料 常會導致難以追蹤的錯誤。
TypeScript 為了提升程式的可預測性與安全性,提供了兩種「唯讀」的概念:
ReadonlyArray<T>– 針對整個陣列的唯讀型別。readonly修飾符 – 可用於陣列、元組 (tuple) 以及物件屬性,讓對應的資料在編譯階段即被視為不可變。
掌握這兩者的用法,不僅可以防止意外的資料變更,還能讓 IDE 更有效地提示錯誤,提升開發效率與程式碼品質。
核心概念
1. ReadonlyArray<T> 基本使用
ReadonlyArray<T> 與一般的 Array<T> 差別在於 所有會改變陣列內容的方法都被移除(例如 push、pop、splice 等),只能使用讀取型方法(如 map、filter、find 等)。
// 一般陣列
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 資料結構(如 immer、Immutable.js)。 |
把 as const 用錯位置 |
as const 只能在字面量(literal)上使用,若放在變數或函式回傳值外層會失效。 |
確保 as const 緊貼在字面量或陣列/物件前,例如 return [a, b] as const;。 |
混用 readonly 與可變方法 |
交叉類型 MutablePush<readonly number[]> 會讓型別變得模糊,導致 IDE 提示失效。 |
盡量保持「全只讀」或「全可變」的設計,避免半只讀的複雜型別。 |
忘記在函式參數加 readonly |
函式內不小心使用 push、splice 改變傳入的陣列,造成呼叫端的資料被意外改動。 |
在任何接受陣列的公共 API 中,都應該使用 readonly(或 ReadonlyArray)作為參數型別。 |
把 readonly 用於類別的 static 成員 |
static readonly 僅保護屬性本身,若屬性是陣列仍可被修改。 |
同樣需要把屬性型別設為 ReadonlyArray<T>,或在建構子中 Object.freeze。 |
最佳實踐小結
- 盡可能在 API 邊界使用只讀型別(函式參數、回傳值)。
- 若陣列內部是物件,考慮使用
DeepReadonly或 immutable 庫。 - 使用
as const讓字面量自動變成只讀,減少手動加readonly的繁瑣。 - 在類別與介面中,將不會變動的屬性標記為
readonly,讓意圖更清晰。 - 配合 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)
開發通用的工具函式(例如 lodash、ramda 的 TypeScript 版本)時,應該把所有接受陣列的函式參數宣告為 readonly,確保函式本身不會產生副作用。
總結
ReadonlyArray<T>與readonly T[]為 TypeScript 提供的陣列唯讀型別,能在編譯階段阻止所有會改變陣列結構的方法。readonly修飾符 不僅適用於陣列,還能標記元組、物件屬性以及類別成員,使資料在被建立後保持不變。- 只讀僅保護第一層結構;若需要 深層不可變,可自行實作遞迴型別
DeepReadonly<T>或使用第三方 immutable 庫。 - 在實務開發中,將只讀型別作為 API 邊界、狀態管理、共享資料的防護盾,能大幅降低因意外修改資料而產生的 bug。
- 避免半只讀的複雜型別,保持程式碼意圖清晰;同時配合 lint 及 IDE 的型別檢查,讓只讀的好處發揮最大效益。
透過本文的概念與範例,你已掌握在 TypeScript 中使用 ReadonlyArray 與 readonly 的要領,未來在撰寫大型前端或全端應用時,記得善用這些型別工具,讓程式碼更安全、更易維護。祝開發順利! 🚀