Generics (泛型) - 讓函式更優雅的接受資料

前言:崎玉還是被罵了 ​

在上一篇 Index Signatures(索引簽章) 中,我們遇到了一個問題:為了讓 formatToURLParams 這個共用函式不報錯,我們被迫修改了 iGetCalendarData 的介面定義,加上了 [key: string]: ...。

typescript
// 上集的解決方案:修改資料介面 (有副作用)
interface iGetCalendarData {
    [key: string]: string | number | number[] | string[]; // <--- 被迫加這行
    date: string;
    userId: number;
    target: number[];
}

雖然程式跑通了,但這產生了副作用:iGetCalendarData 變得不再嚴謹,它現在允許我們塞入任何奇怪的屬性(例如 enable: true),這失去了 TypeScript 型別安全 (Type Safety) 的初衷。

今天,我們要用更優雅的方式—— 泛型 (Generics) ,在不汙染原始資料介面的情況下解決這個問題。

什麼是泛型 (Generics)? ​

如果說函式的參數是用來接收「值 (Values)」的,那麼 泛型 (Generics) 就是用來接收「型別 (Types)」的。 你可以把它想像成型別的變數。 通常我們用 <T> (Type 的縮寫) 來表示。 雖然說泛型很強大,但是他的概念比較抽象,尤其通常用 T、J、K 這種單字母來表示,讓初學 Typescript 的我們,更難理解他的用途。

所以讓我們延伸上一篇文章的例子來做舉例,重構 formatToURLParams: 我們不再要求傳入的資料必須「長得像某個特定介面」,而是告訴函式:「我會給你一個型別 T ,請你暫時把它當作一個物件來處理」,至於這個物件長的如何?你先別問,你就當他是 T 吧 (怎麼和客戶提要求一個樣) 。

ts
// 使用泛型 T,並限制 T 必須是鍵值對物件 (Record)
function formatToURLParams<T extends Record<string, any>>(data: T) {
    const params = new URLSearchParams();

    // 因為我們告訴 TS data 是 Record<string, any>,所以 Object.keys 沒問題
    Object.keys(data).forEach((key) => {
        const value = data[key as keyof T]; // 取得對應的值

        if (Array.isArray(value)) {
            if (value.length > 0) {
                value.forEach((item) => {
                    params.append(key, String(item));
                });
            } else {
                params.append(key, '');
            }
        } else {
            params.append(key, String(value));
        }
    });

    return params;
}

使用方式 ​

現在,我們可以還原 iGetCalendarData 到最乾淨的狀態:

ts
// 乾淨的介面,不需要 Index Signature
interface iGetCalendarData {
    date: string;
    userId: number;
    target: number[];
}

const myData: iGetCalendarData = {
    date: '2025-01-18',
    userId: 11,
    target: [1, 2, 3]
};

// TS 自動推斷 T 為 iGetCalendarData,且不會報錯
const queryString = formatToURLParams(myData);

深入解析:為什麼是 extends Record? ​

咦? 不是說 只要叫 T 就好了嗎? 為什麼還要加上 extends Record<string, any> 呢? 在上面的程式碼中,我們寫了 <T extends Record<string, any>>。這段語法是很重點的實踐方式,它稱為泛型約束 (Generic Constraints)。

為什麼不能只寫 <T>? 如果只寫 function formatToURLParams<T>(data: T),TypeScript 會認為 T 可能是任何東西(數字、字串、布林值)。當你在函式內呼叫 Object.keys(data) 時,TS 會報錯,因為它不確定 T 到底是不是一個物件。

為什麼不用特定型別? 這就是泛型最強大的地方。讓我們比較一下兩種寫法:

1. 特定型別約束 (不常使用) ​

ts
// 限制 T 必須包含 id 屬性
function helper<T extends { id: number }>(data: T) { ... }

這種寫法雖然安全,但靈活性極低。傳入的資料必須要有 id。如果我想處理 iGetCalendarData (它有 userId 但沒有 id),這個函式就廢了。

2. 寬鬆型別約束 (用途最廣) ​

Record<string, any> 的意思是:這是一個物件,它的屬性名稱是字串,值是任意型別。 這提供了最大的靈活性。無論傳入的是 iGetCalendarData、iUserInfo 還是 iProduct,只要它是個物件,這個公用函式都能處理。

泛型的優勢 ​

  1. 保持介面純淨:我們不需要為了配合公用函式去修改資料的 Interface。
  2. 型別安全 (Type Safety): 使用泛型後,TypeScript 依然知道 data 原本的長相。
    ts
    function doSomething<T>(data: T): T {
        return data;
    }
    const result = doSomething(myData);
    // result 的型別依然是 iGetCalendarData,而不是 any
    // 如果我們在函式內亂改屬性,TS 會警告我們。
  3. 高重用性:寫一次邏輯,可以支援多種不同結構的資料。

進階思考 ​

在之後還會有很多文章,甚至想寫一系列 封裝 axios 的深入淺出 (血淚史),裡面會提到很多泛型的進階用法,例如多重泛型、預設泛型、條件型別等等。 今天先讓我們掌握泛型的核心概念,並且學會如何在實際專案中應用它,讓我們的程式碼更優雅、更安全。 並期待哪天 Ops 可以把自己挖的坑填起來吧~。