
一句話:泛型解決複製貼上,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 才知道。typeof 和 instanceof 可以處理基本情況,遇到自訂 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,聽起來很瘋但超實用。
系列文章
- (一)命名轉換和 Utility Types
- (二)泛型和 Type Guard(本篇)
- (三)Conditional Types 與實戰整合