cover

一句話:泛型解決複製貼上,Type Guard 解決 runtime 型別判斷。

先講結論

泛型(Generic)聽起來很學術,但你每天都在用——Array<number>Promise<string> 裡的 <> 就是泛型。問題不是「泛型怎麼寫」,而是什麼時候該自己定義泛型。答案很簡單:當你發現自己在複製貼上差不多的函式,只是型別不一樣的時候。

Type Guard 則是另一回事:型別檢查是 compile time 的事,但有些東西只有 runtime 才知道。Type Guard 就是你跟 TypeScript 說「放心,我已經確認過了」的方式。


泛型:不要再複製貼上了

API Client——最經典的場景

你有一堆 API endpoint,每個回傳的資料結構不同,但 fetch 的邏輯都一樣。不用泛型的話:

async function fetchUsers(): Promise<User[]> {
  const res = await fetch("/api/users");
  if (!res.ok) throw new Error("Failed");
  return res.json();
}
 
async function fetchProducts(): Promise<Product[]> {
  const res = await fetch("/api/products");
  if (!res.ok) throw new Error("Failed");
  return res.json();
}
// 複製貼上 10 次... 你不覺得哪裡不對嗎?

用泛型,一個 function 搞定:

async function fetchApi<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Failed to fetch ${url}`);
  return res.json();
}
 
const users = await fetchApi<User[]>("/api/users");       // User[]
const products = await fetchApi<Product[]>("/api/products"); // Product[]

再進階一點,把錯誤處理也包進去:

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };
 
async function safeApi<T>(url: string): Promise<ApiResult<T>> {
  try {
    const res = await fetch(url);
    if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
    return { ok: true, data: await res.json() };
  } catch (e) {
    return { ok: false, error: e instanceof Error ? e.message : "Unknown" };
  }
}
 
const result = await safeApi<User>("/api/user/1");
if (result.ok) {
  console.log(result.data.name);  // TS 知道是 User
} else {
  console.error(result.error);    // TS 知道是 string
}

這個 ApiResult<T> 其實就是 Discriminated Union——等一下會詳細講。

React 泛型元件

寫 React 的話,泛型元件讓你的 component 靈活又型別安全:

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyExtractor: (item: T) => string;
}
 
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  if (items.length === 0) return <p>沒有資料</p>;
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
 
// T 會自動推導,不用手動指定
<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

工具函式

泛型讓你的工具函式像原生方法一樣通用:

// 根據某個 key 去重
function uniqueBy<T>(arr: T[], keyFn: (item: T) => string | number): T[] {
  const seen = new Set<string | number>();
  return arr.filter((item) => {
    const key = keyFn(item);
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}
 
const uniqueUsers = uniqueBy(users, (u) => u.id);  // User[]
 
// 分組
function groupBy<T>(arr: T[], keyFn: (item: T) => string): Record<string, T[]> {
  return arr.reduce((groups, item) => {
    const key = keyFn(item);
    (groups[key] ??= []).push(item);
    return groups;
  }, {} as Record<string, T[]>);
}

Type Guard:runtime 的型別證明

型別檢查是 compile time 的事,但 API 回傳的資料長什麼樣,只有 runtime 才知道。typeofinstanceof 可以處理基本情況,遇到自訂 interface 就不夠用了。

is keyword——自訂 Type Guard

interface Cat { type: "cat"; meow(): void }
interface Dog { type: "dog"; bark(): void }
type Pet = Cat | Dog;
 
function isCat(pet: Pet): pet is Cat {
  return pet.type === "cat";
}
 
function handlePet(pet: Pet) {
  if (isCat(pet)) {
    pet.meow();  // TS 知道是 Cat
  } else {
    pet.bark();  // TS 知道是 Dog
  }
}

更實務的場景——判斷 API 回傳是成功還是錯誤:

interface ApiSuccess<T> { status: "success"; data: T }
interface ApiError { status: "error"; message: string; code: number }
type ApiResponse<T> = ApiSuccess<T> | ApiError;
 
function isApiSuccess<T>(res: ApiResponse<T>): res is ApiSuccess<T> {
  return res.status === "success";
}
 
const response = await fetchApi<ApiResponse<User[]>>("/api/users");
if (isApiSuccess(response)) {
  renderUserList(response.data);   // data 是 User[]
} else {
  showError(response.message);     // 是 ApiError
}

Discriminated Union——用一個 key 分辨一切

這是我覺得 TypeScript 最強大的 pattern,沒有之一。核心概念:每個 union 成員都有一個共同的 literal type property,TypeScript 根據它自動 narrow 型別。

// API 請求的四種狀態——一個 switch 搞定
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };
 
function renderUserProfile(state: RequestState<User>) {
  switch (state.status) {
    case "idle":    return <p>點擊載入</p>;
    case "loading": return <Spinner />;
    case "success": return <UserCard user={state.data} />;
    case "error":   return <ErrorBanner message={state.error} />;
  }
}

每個 case 裡面,TypeScript 都知道 state 是哪個型別。不用額外判斷、不用 type assertion。這就是為什麼用過 Discriminated Union 的人回不去了。

satisfies——TS 4.9 的好東西

satisfies 解決一個很常見的痛點:你想確認一個值符合某個型別,但不想失去具體的 literal type 資訊。

type Color = "red" | "green" | "blue";
type ColorMap = Record<Color, string | number[]>;
 
// 用型別註解——型別變寬了
const colorsAnnotated: ColorMap = {
  red: "#ff0000", green: [0, 255, 0], blue: "#0000ff",
};
colorsAnnotated.red.toUpperCase();  // 報錯!TS 不確定是 string 還是 number[]
 
// 用 satisfies——驗證型別但保留具體資訊
const colors = {
  red: "#ff0000", green: [0, 255, 0], blue: "#0000ff",
} satisfies ColorMap;
 
colors.red.toUpperCase();     // OK!TS 知道 red 是 string
colors.green.map((v) => v);   // OK!TS 知道 green 是 number[]

最佳使用時機:config 物件、route 定義、i18n 翻譯——這些你想確保結構正確,但每個值的具體型別不同的場景。


泛型和 Type Guard 是 TypeScript 讓人上癮的起點。下一篇講 Conditional Types 和 Mapped Types——型別系統裡的 if-else 和 for-loop,聽起來很瘋但超實用。

系列文章

延伸閱讀