本文 AI 產出,尚未審核

TypeScript 陣列展開運算子與型別


簡介

在日常開發中,陣列是最常使用的資料結構之一。它不僅負責儲存同類型的資料,還提供了許多便利的操作方法。隨著 ES6 引入 展開運算子(spread operator),我們可以用更直覺的語法來合併、複製或展開陣列。而在 TypeScript 中,展開運算子不只是語法糖,還會牽涉到型別推斷型別安全的問題。

本單元將說明如何在 TypeScript 中正確使用陣列展開運算子,並結合型別系統避免常見錯誤,幫助初學者快速上手、讓中級開發者在大型專案中亦能安全、有效地運用。


核心概念

1. 展開運算子的基本語法

const a = [1, 2, 3];
const b = [4, 5];
const c = [...a, ...b]; // [1, 2, 3, 4, 5]
  • ... 會把 可迭代物件(如陣列、字串、Set)展開成單一元素,再放入新的陣列或函式參數中。
  • 在 TypeScript 中,展開後的結果型別會根據來源陣列的型別自動推斷。

2. 型別推斷與聯合型別

const nums: number[] = [1, 2];
const strs: string[] = ['a', 'b'];
const mixed = [...nums, ...strs]; // inferred as (string | number)[]
  • 當展開的陣列型別不一致時,TypeScript 會產生 聯合型別 (string | number)。
  • 若期望更嚴格的型別,可使用 型別斷言泛型 來限制。

3. 使用泛型保留元素順序與型別

function merge<T, U>(a: T[], b: U[]): (T | U)[] {
  return [...a, ...b];
}
const result = merge([1, 2], ['x', 'y']); // (number | string)[]
  • 透過泛型 TU,函式可以接受任何型別的陣列,並正確推斷回傳的聯合型別。
  • 若想保留 元組(tuple)的型別資訊,需要使用 條件型別展開元組

4. 展開元組(Tuple)與可變長度

type Pair = [string, number];
const p: Pair = ['id', 42];
const extended = [...p, true]; // inferred as (string | number | boolean)[]
  • 展開元組會把每個元素的型別依序展開,最終型別會是所有元素的 聯合型別
  • 若要保持元組結構,可使用 as const... 搭配 infer 的技巧。

5. 只讀陣列(ReadonlyArray)與展開

const ro: ReadonlyArray<number> = [10, 20];
const copy = [...ro]; // copy 為 number[]
  • 展開運算子會 解除只讀限制,產生可變的普通陣列。
  • 如果希望保留只讀屬性,需在展開後再使用 as constReadonlyArray 包裝。

程式碼範例

範例 1:簡易陣列合併與型別推斷

const fruits = ['apple', 'banana'];
const numbers = [1, 2, 3];

// 展開後會得到 (string | number)[]
const mixed = [...fruits, ...numbers];

mixed.forEach(item => {
  // TypeScript 會提示 item 可能是 string 或 number
  if (typeof item === 'string') {
    console.log('水果:', item);
  } else {
    console.log('數字:', item);
  }
});

重點:展開不同型別的陣列會自動產生聯合型別,使用時需做好型別判斷。

範例 2:使用泛型的 merge 函式

function merge<T, U>(a: T[], b: U[]): (T | U)[] {
  return [...a, ...b];
}

const nums = [10, 20];
const bools = [true, false];

const merged = merge(nums, bools); // (number | boolean)[]

技巧:若希望回傳的型別保持原始陣列的順序,可改寫為 Array<T | U>,或使用條件型別取得更精細的型別。

範例 3:保留元組型別資訊

type Point = [number, number];
const p1: Point = [0, 0];
const p2: Point = [10, 20];

// 直接展開會失去元組資訊
const flat = [...p1, ...p2]; // (number)[]
/* 想要保留兩個元組的結構,可使用以下技巧 */
type Concat<T extends any[], U extends any[]> = [...T, ...U];
type TwoPoints = Concat<Point, Point>; // [number, number, number, number]

const points: TwoPoints = [...p1, ...p2];

說明:透過 條件型別 + 展開元組,可以在編譯期保留完整的長度與型別資訊。

範例 4:只讀陣列的安全展開

const readonlyNums: ReadonlyArray<number> = [5, 6, 7];

// 展開後得到可變陣列
const mutableCopy = [...readonlyNums];
mutableCopy.push(8); // OK

// 若想保持只讀,可再包裝一次
const stillReadonly: ReadonlyArray<number> = [...readonlyNums] as const;

最佳實踐:在需要傳遞只讀資料給外部函式時,避免直接展開成可變陣列,以免意外修改。

範例 5:函式參數的展開與型別限制

function sum(...values: number[]): number {
  return values.reduce((a, b) => a + b, 0);
}

const arr = [1, 2, 3];
const total = sum(...arr); // 正常

// 若傳入非 number 陣列會在編譯期錯誤
const strArr = ['a', 'b'];
// const wrong = sum(...strArr); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.

重點:使用展開運算子傳遞參數時,TypeScript 會檢查每個元素的型別,確保函式簽名相容。


常見陷阱與最佳實踐

陷阱 說明 解決方式
展開後失去只讀屬性 ...readonlyArray 會產生可變陣列。 若需保持只讀,使用 as const 或重新包裝成 ReadonlyArray
聯合型別過寬 合併不同型別陣列時,型別會變成寬鬆的聯合型別,導致後續操作需要額外型別保護。 使用 泛型型別斷言 (as) 限制回傳型別,或在設計資料結構時盡量保持同質性。
展開元組失去長度資訊 直接展開會變成普通陣列,失去元組的固定長度檢查。 利用 條件型別 (Concat<T,U>) 或 as const 重新建立元組型別。
展開非可迭代物件 嘗試 ...nonIterable 會在編譯期或執行期拋錯。 確認變數實際為 Array、ReadonlyArray、Set、Map、string 等可迭代物件。
過度使用 any 為了避免型別錯誤而直接使用 any,失去 TypeScript 的安全保障。 儘量使用 具體型別泛型,或在必要時使用 unknown 再做型別縮小。

最佳實踐

  1. 保持同質性:在同一個陣列中盡量存放相同型別的元素,減少不必要的聯合型別。
  2. 使用 as const:對於不會變更的字面值陣列,使用 as const 讓 TypeScript 推斷為 只讀且具體的字面型別
  3. 泛型函式:若需要處理多種陣列,寫成泛型函式能同時保留型別資訊與可重用性。
  4. 避免過度展開:在大型陣列或深層結構中頻繁展開會產生大量臨時陣列,影響效能。可考慮 Array.concat迭代器 替代。

實際應用場景

  1. Redux / Zustand 狀態合併

    const newTodos = [...state.todos, action.payload];
    // TypeScript 會保證 newTodos 為 Todo[]
    

    展開運算子讓我們以 不可變 的方式新增元素,同時保留型別安全。

  2. API 回傳分頁資料合併

    const allItems = [...prevPage.items, ...nextPage.items];
    // allItems 仍然是 Item[],方便後續渲染或過濾
    
  3. 函式參數的可變長度

    function logAll(...messages: string[]) {
      console.log(...messages);
    }
    const msgs = ['a', 'b', 'c'];
    logAll(...msgs);
    

    透過展開,我們可以把陣列直接傳入接受不定參數的函式。

  4. 測試資料的快速組裝

    const baseUser = { id: 1, name: 'Alice' } as const;
    const extra = [{ role: 'admin' }, { active: true }];
    const user = { ...baseUser, ...extra[0], ...extra[1] };
    // user 的型別會是 { id: 1; name: "Alice"; role: "admin"; active: true }
    
  5. 組合多個設定檔

    const defaultConfig = { timeout: 3000, retries: 3 } as const;
    const envConfig = { timeout: 5000 } as const;
    const finalConfig = { ...defaultConfig, ...envConfig };
    // finalConfig 的型別為 { timeout: number; retries: number }
    

總結

  • 展開運算子是 ES6 提供的強大語法,讓陣列合併、複製、參數傳遞變得簡潔。
  • TypeScript 中,展開會同時觸發型別推斷,可能產生聯合型別、失去只讀屬性或元組長度資訊。
  • 透過 泛型、條件型別、as const 等技巧,我們可以在保留型別安全的同時,享受展開運算子的便利。
  • 識別常見陷阱(如過寬的聯合型別、只讀屬性遺失)並遵守最佳實踐,能讓程式碼更健全、更易維護。

掌握了這些概念與技巧後,你就能在日常開發、狀態管理、API 整合等各種情境下,安全且高效地使用陣列展開運算子,讓 TypeScript 的型別系統為你的程式碼保駕護航。祝你寫程式愉快!