iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Modern Web

我與型別的 30 天約定:TypeScript 入坑實錄系列 第 12

Day 12|泛型進階:預設值、多參數與更嚴格的約束

  • 分享至 

  • xImage
  •  

泛型(Generics)= 型別的變數。你先寫一個「模板」,真正用的時候再把型別塞進去。今天把三個常見升級招式講清楚:預設值多參數約束(extends / keyof)


1) 泛型預設值:不指定型別也能用得舒服

直覺比喻

就像表單有預設選項。大多數情況用預設就夠,特殊情況再自己改。

什麼時候用

  • 90% 以上的情境會用同一種型別,但你仍想保留客製的彈性。
  • 寫工具函式/元件庫時,降低使用者心智負擔。

範例(含註解)

// T 有預設值 string:呼叫者不填也能跑
function createArray<T = string>(length: number, value: T): T[] {
  return Array(length).fill(value);
}

const a = createArray(3, "hi");   // 推論 T=string(用預設)
const b = createArray<number>(3, 99); // 手動指定 T=number

常見錯誤 & 修正

function makePair<K = string, V = number>(k: K, v: V) {
  return [k, v] as [K, V];
}

makePair("id", 1);        // OK  → K=string, V=number(剛好等於預設)
makePair(true, 1);        // OK  → K=boolean(覆蓋預設)
makePair("id", "oops");   // OK  → V=string(覆蓋預設)

// 反例:不要寫死 value 的型別,否則預設就沒意義
// function bad<T = string>(x: T, y: string) { ... }  // y 寫死會讓 T=string顯得突兀

心法小結

  • 預設值是「降低使用成本」,不是「鎖死型別」。
  • 若函式參數已經把 T 推論得很準,就不必硬加預設。

2) 多個泛型參數:像組積木,彼此互相呼應

直覺比喻

<T, U, V> 像三個空抽屜,執行時再放進對應物品。抽屜之間還能「互相參考」。

什麼時候用

  • 需要描述「兩個不同型別之間的配對、對應、合併」。
  • 例如 Map<K, V>Pair<L, R>zip<A, B> 等。

範例(基礎)

function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

const p1 = pair("id", 100);   // [string, number]
const p2 = pair(42, { ok: true }); // [number, { ok: boolean }]

範例(進階:兩者還能互相限制)

// 需求:保證 value 的型別,必須是 obj[key] 對應的型別
function setProp<
  Obj extends object,
  Key extends keyof Obj
>(obj: Obj, key: Key, value: Obj[Key]) {
  obj[key] = value;
}

const user = { id: "u1", age: 20 };

setProp(user, "age", 99);      // OK  value 是 number
setProp(user, "id", "u2");     // OK  value 是 string
// setProp(user, "age", "oops"); // ❌ Type 'string' is not assignable to type 'number'

心法小結

  • 多參數不是越多越好;每個參數都要有存在意義
  • 盡量讓參數之間有關聯(像上例的 Key extends keyof Obj),TS 才能幫你「對準」。

3) 更嚴格的約束:extends + keyof 把邊界畫清楚

直覺比喻

extends 像「入場門檻」,不符合條件就不讓進。

keyof T 像「T 的菜單」,只能點菜單上有的。

3.1 extends:先檢查,再通行

// 只接受有 length 屬性的東西(字串、陣列、自製物件都可)
function getLength<T extends { length: number }>(x: T) {
  return x.length;
}

getLength("hello");   // OK
getLength([1, 2, 3]); // OK
// getLength(123);    // ❌ Property 'length' does not exist on type 'number'

進一步:限制為物件類型

// 只允許物件(排除 number/string/boolean 等原始型別)
function keysOf<T extends object>(obj: T) {
  return Object.keys(obj) as Array<keyof T>;
}

小技巧:如果你想表達「鍵是字串、值隨意」,可用 Record<string, unknown>。


3.2 keyof:鍵名也能變型別

type User = { id: string; age: number };
type UserKeys = keyof User; // "id" | "age"

function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

const u = { id: "u1", age: 20, name: "Bob" };

pick(u, ["id", "age"]);   // OK → { id: string; age: number }
// pick(u, ["email"]);    // ❌ Argument of type '("email")[]' is not assignable to parameter of type '("id" | "age" | "name")[]'

為什麼 Pick<T, K> 能這麼準?

  • K extends keyof T:K 只能是 T 的鍵。
  • Pick<T, K>:把 T 裡屬性名在 K 這一組的挑出來,形成新的物件型別。

4) 逐步推論示範:TS 到底「怎麼想」?

我們用一段程式,拆解 TS 腦中發生的事:

function getProp<T, K extends keyof T>(obj: T, key: K) {
  return obj[key]; // 型別是 T[K]
}

const book = { title: "TS", pages: 300 as const };

const t = getProp(book, "title");
// 推論流程:
// 1) T = { title: string; pages: 300 }(字面量推論)
// 2) K = "title"(因為第二個參數)
// 3) 回傳型別 = T[K] = T["title"] = string

const p = getProp(book, "pages");
// 回傳型別 = T["pages"] = 300(字面量型別,不是 number!)

小觀念:as const 讓值變成最窄(literal)型別,回傳就更精準。


5) API 範例升級:預設值 + 約束 + keyof,一次上

需求:

  • fetchData<T> 預設 T = unknown(比 {} 更合理,因為「未知」比「空物件」更精準)
  • 只接受 GETPOST
  • 提供 select<T, K extends keyof T> 幫你「挑欄位」
type HttpMethod = "GET" | "POST";

type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
};

async function fetchData<T = unknown>(
  url: string,
  method: HttpMethod = "GET"
): Promise<ApiResponse<T>> {
  const res = await fetch(url, { method });
  return res.json();
}

// 挑欄位的小工具(可串在一起用)
function select<T, K extends keyof T>(data: T, keys: K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = data[k];
  return out;
}

// 使用:
type User = { id: string; name: string; email: string };

fetchData<User>("/api/user").then((resp) => {
  const slim = select(resp.data, ["id", "name"]); // { id: string; name: string }
});

6) 常見踩坑 & 矯正示例

坑 A:用空陣列導致 T 推成 never

function first<T>(arr: T[]): T {
  return arr[0];
}

const x = first([]);             // 推論 T=never → 回傳 never(很難用)
const y = first<string>([]);     // ✅ 手動指定 T

坑 B:過度抽象,讓使用者難以指定

// 過度抽象,使用者不知道該怎麼餵 T
function wrap<T>(x: T) { return [x]; }

// 改善:從參數推論 + 文件化
const w1 = wrap("hi"); // T=string,自然

坑 C:忘了加 extends 導致屬性取值報錯

function values<T>(obj: T) {
  return Object.keys(obj).map(k => obj[k]); // ❌ obj[k] 報錯,因為 T 可能不是 object
}

// 修正:
function values<T extends object>(obj: T) {
  return Object.keys(obj).map(k => (obj as any)[k]); // 或搭配 Record 來更安全
}
// 或者更嚴謹:
function values2<T extends Record<string, unknown>>(obj: T) {
  return Object.keys(obj).map(k => obj[k]);
}

7) 檢查清單(你寫泛型時自我檢查)

  • [ ] 這個泛型參數 能被推論 嗎?能的話就不要逼使用者填。
  • [ ] 需要 預設型別 嗎?預設是否合理(unknown 通常比 {} 更貼切)?
  • [ ] 能否用 extends 把「入場門檻」講清楚?
  • [ ] 若關係是「物件與鍵」,是否該用 keyof
  • [ ] 是不是「過度抽象」導致可讀性下降?能否改成更直接的聯合型別/具體型別?

上一篇
Day 11|泛型 Generics:讓型別變得聰明又彈性
系列文
我與型別的 30 天約定:TypeScript 入坑實錄12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言