FE Day 4 TypeScript 進階:泛型、收窄與工具型別
執行需求:CPU 可跑。今天是「前端開發實戰」系列的第四篇。Day 3 我們把 TypeScript 的型別基礎打好了,今天往前再推一層:泛型(generics)讓你寫「可重用的型別函式」、型別收窄(narrowing)讓你在 runtime 與 compile time 之間架起橋樑、工具型別(utility types)讓你從現有型別衍生新形狀而不必重寫。這三個觀念組合起來,就是 React 與 Next.js 原始碼裡那些「型別簽名」的全部基礎。讀完之後,你應該能在自己的元件裡設計可重用的泛型 Hook、寫出嚴謹的錯誤處理函式,並且看懂社群套件的 .d.ts 檔案。整篇閱讀時間約三十五分鐘。
引言
寫後端的工程師對「泛型」並不陌生:Java 有 List、C# 有 IEnumerable、Rust 與 Swift 更泛型。TypeScript 把這個觀念搬進來,語法一樣用尖括號標型別參數,但運作方式更接近 Go 的泛型或 C++ 的 templates——你寫的是「對型別操作的函式」,編譯器在每個呼叫點把型別代進去。
「型別收窄」是 TypeScript 獨有的觀念。寫 Java 的人會告訴你「instanceof 判斷完之後,編譯器不會自動更新變數的型別」;TypeScript 反過來:只要你在 runtime 做了一個有效的型別檢查(typeof、in、instanceof),編譯器就會自動把變數的型別縮到「最精準」的範圍。這個能力讓你可以同時處理「可能是 A 或 B」的 union,又能保證分支裡的操作型別正確。
「工具型別」是 TypeScript 內建的型別運算子:Pick、Omit、Partial、Required、Readonly、Record 等。React 與 Next.js 的型別檔幾乎都是這些工具型別的組合。理解它們之後,你可以從一個完整的 User 型別快速衍生出「建立輸入」、「更新輸入」、「唯讀檢視」三個變形,不必手動重寫欄位。
今天的目標有三個:第一,把泛型的「型別參數」、「約束」、「預設」三個概念釐清;第二,用真實例子把收窄手法(typeof、in、instanceof、is)一個個演練;第三,理解五個常用工具型別的語意,並把它們套到 Day 3 建立的預約系統型別檔上。最後我們會用一個完整實作把這三件事組合起來:寫一個泛型 useResource Hook,搭配收窄處理三種狀態,再用工具型別衍生出不同的 props 形狀。
泛型:對型別操作的函式
泛型的本質是「把型別也當參數傳進去」。下面的函式對任何「有 id 欄位」的物件都能用:
// src/generics/basic.ts
// 最小的泛型函式:找出陣列裡 id 對應的物件
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
const bookings = [
{ id: 1, customerName: "王小明" },
{ id: 2, customerName: "李小華" },
];
const found = findById(bookings, 1);
console.log(found?.customerName);
// 輸出:王小明
這支函式展示了泛型三個關鍵元素:第一,<T> 是型別參數,呼叫時 TypeScript 會自動推論(這裡推論成 { id: number; customerName: string });第二,extends { id: number } 是約束,規定 T 必須至少有 id 這個欄位,否則 item.id 會報錯;第三,回傳型別 T | undefined 因為 Array.find 找不到時回 undefined。後端工程師可以把它對應到 Java 的 T extends Comparable<T> 或 C# 的 where T : IEntity。
多型別參數與預設
當一個函式需要兩個以上型別參數時,可以並列寫出來;如果某個參數經常有一個預設型別,可以給它預設值:
// src/generics/multi.ts
// 多型別參數 + 預設型別:把某個 key 的值轉成另一個型別
type ApiMapping<K extends string, V = unknown> = Record<K, V>;
const errorMap: ApiMapping<"code" | "message", string> = {
code: "BOOKING_NOT_FOUND",
message: "找不到這筆預約",
};
console.log(errorMap.code);
// 輸出:BOOKING_NOT_FOUND
這裡 V = unknown 是預設型別:呼叫時不指定就用 unknown,指定就用指定值。React 19 的 useState<T = undefined>、useRef<T = undefined> 都用了這種預設語法。對後端工程師來說,「給型別參數預設值」就像 Java 的「diamond operator」或 C# 的 default(T)——降低使用門檻。
泛型在 React 元件中的角色
React 內建的 useState、useRef、useReducer 都是泛型 Hooks。當你寫 useState<number>(0),TypeScript 就能保證 setter 只接受 number、未取值永遠是 number 或 null(依你的初始值)。Day 6 會大量用到 useState,Day 10 會寫自己的泛型自訂 Hook;今天的目標是先把「泛型即型別參數」的觀念釐清。
型別收窄:runtime 與 compile time 的橋樑
當一個變數的型別是 union(例如 string | number、User | null),TypeScript 會要求你在使用前「縮到一個具體型別」。這個過程叫「收窄」。下面把四個常見手法逐一介紹。
typeof 收窄
最基礎的收窄:對原始型別(primitive)用 typeof。JavaScript 裡 typeof 回傳的字串有限("string"、"number"、"boolean"、"undefined"、"object"、"function"、"symbol"、"bigint"),TypeScript 也認得這幾個:
// src/narrowing/typeof.ts
// typeof 收窄:對原始型別的 union
function formatValue(value: string | number): string {
if (typeof value === "string") {
// 這裡 value 已經被收窄成 string,可以呼叫 .toUpperCase()
return value.toUpperCase();
}
// 這裡 value 已經被收窄成 number,可以呼叫 .toFixed()
return value.toFixed(2);
}
console.log(formatValue("hello"));
// 輸出:HELLO
console.log(formatValue(3.14159));
// 輸出:3.14
注意 TypeScript 在 else 分支裡自動把 value 視為 number,不需要你寫 value as number。這就是「控制流分析」(control flow analysis):編譯器讀懂你的 if/else,把可能的型別逐一縮小。後端寫過 Kotlin smart cast 或 Swift 的 if let 的人會覺得非常熟悉。
in 收窄
當物件的形狀不同、但都是物件時,可以用 in 運算子判斷某個屬性是否存在:
// src/narrowing/in.ts
// in 收窄:對物件 union 判斷欄位是否存在
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
// 這裡 animal 已經是 Cat
animal.meow();
} else {
// 這裡 animal 已經是 Dog
animal.bark();
}
}
const kitty: Cat = { meow: () => console.log("喵") };
speak(kitty);
// 輸出:喵
in 的概念來自 JavaScript 的 key in obj 語法;TypeScript 把這個 runtime 檢查變成型別資訊。實務上常用在「後端 API 回傳不同形狀的物件」或「表單欄位可能有 / 可能沒有」的情境。
instanceof 收窄
對類別(class)實例用 instanceof:
// src/narrowing/instanceof.ts
// instanceof 收窄:對類別實例
class ValidationError extends Error {
field: string;
constructor(field: string, message: string) {
super(message);
this.field = field;
}
}
class NetworkError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
function describe(err: Error) {
if (err instanceof ValidationError) {
// 這裡 err.field 可以存取
return `欄位 ${err.field} 驗證失敗:${err.message}`;
}
if (err instanceof NetworkError) {
// 這裡 err.status 可以存取
return `HTTP ${err.status}:${err.message}`;
}
return err.message;
}
console.log(describe(new ValidationError("email", "格式不正確")));
// 輸出:欄位 email 驗證失敗:格式不正確
實務上 React 的錯誤邊界(error boundary)會大量用到 instanceof:丟出來的錯誤可能是網路錯誤、可能是驗證錯誤、可能是程式錯誤,每種要顯示不同的 UI。
使用者自訂 type guard
當上面的手法都不夠用時,可以自己寫「型別守衛函式」(type guard)。回傳型別用「參數 is 型別」這種特殊簽章:
// src/narrowing/type-guard.ts
// 自訂 type guard:把 union 收縮成其中一種
type ApiSuccess = { ok: true; data: unknown };
type ApiFailure = { ok: false; error: string };
type ApiResult = ApiSuccess | ApiFailure;
// 型別守衛函式:傳入 ApiResult,告訴編譯器「回傳 true 時是 ApiSuccess」
function isSuccess(result: ApiResult): result is ApiSuccess {
return result.ok === true;
}
function handle(result: ApiResult): string {
if (isSuccess(result)) {
// 這裡 result 已經是 ApiSuccess,可以存取 .data
return `成功:${JSON.stringify(result.data)}`;
}
// 這裡 result 已經是 ApiFailure,可以存取 .error
return `失敗:${result.error}`;
}
console.log(handle({ ok: true, data: { id: 1 } }));
// 輸出:成功:{"id":1}
「參數 is 型別」這個語法是 TypeScript 獨有的——函式本體回傳布林值,但回傳型別宣告告訴編譯器「當回傳 true 時,傳入的參數型別是 X」。這讓你可以把複雜的 union 收縮邏輯抽成可重用的函式,而不必每個呼叫點重複 if 判斷。實務上常見的場景是「判斷某個物件是否為指定形狀」、「判斷字串是否符合特定 pattern」、「判斷數字是否在合理範圍」。
工具型別:從現有型別衍生新形狀
TypeScript 內建了十幾個工具型別,幾乎所有 React 元件設計都會用到。下面把五個最常用的介紹一遍,每個都對應到預約系統的真實情境。
| 工具型別 | 語意 | 典型用途 |
|---|---|---|
Pick |
從 T 挑出 K 指定的欄位 | 「只需要姓名電話的查詢表單」 |
Omit |
從 T 移除 K 指定的欄位 | 「建立輸入:省略 id 與時間戳記」 |
Partial |
把 T 所有欄位變可選 | 「更新輸入:所有欄位都能不傳」 |
Required |
把 T 所有欄位變必填 | 「送出前保險:所有欄位都有值」 |
Readonly |
把 T 所有欄位變唯讀 | 「從 API 拿來的資料不應被竄改」 |
Record |
建立 key-value 對應的物件型別 | 「狀態對應表、列舉對應中文字串」 |
這六個工具型別幾乎能涵蓋 80% 的真實情境。其餘像 ReturnType、Parameters、NonNullable、Exclude、Extract 也是常用,但今天先打住,明天 Day 5 進入 React 元件設計時,會再回頭用到 ComponentProps 等更進階的工具。
把工具型別套到預約系統
用 Day 3 建立的 Booking 為例,示範怎麼用工具型別衍生不同形狀:
// src/types/booking-derived.ts
// 用工具型別從 Booking 衍生各種輸入與輸出形狀
import type { Booking } from "./booking";
// 建立預約:省略 id 與時間戳記
export type BookingCreateInput = Omit<
Booking,
"id" | "createdAt" | "updatedAt" | "status"
>;
// 部分更新:所有欄位都可選,但型別不變
export type BookingUpdateInput = Partial<
Omit<Booking, "id" | "createdAt" | "updatedAt">
>;
// 清單顯示:只挑出表格需要的欄位
export type BookingListItem = Pick<
Booking,
"id" | "customerName" | "serviceId" | "startAt" | "status"
>;
// 詳細頁:唯讀,避免被元件意外修改
export type BookingDetail = Readonly<Booking>;
// 狀態對應表:用 Record 把列舉對應到中文顯示
export type BookingStatusLabel = Record<Booking["status"], string>;
注意這段用到 Booking["status"] 這種「型別查詢」語法:Booking 物件的 status 欄位型別是 BookingStatus,所以 Booking["status"] 就是那個聯合型別。這種寫法比直接寫 BookingStatus 更穩定:未來改了 Booking 的 status 欄位型別,這邊會自動跟著改。
完整實作:泛型 useResource Hook + 收窄
把上面三個觀念整合起來:寫一個泛型 useResource Hook,用 type guard 處理三種狀態,並用工具型別衍生不同的 props 形狀:
// src/hooks/useResource.ts
// 泛型 Hook:用同一支程式碼處理「載入中 / 成功 / 失敗」三種狀態
import { useEffect, useState } from "react";
// 用型別守衛把 union 縮到具體狀態
export type ResourceState<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
export function isSuccess<T>(state: ResourceState<T>): state is { status: "success"; data: T } {
return state.status === "success";
}
export function isError<T>(state: ResourceState<T>): state is { status: "error"; error: Error } {
return state.status === "error";
}
export function useResource<T>(fetcher: () => Promise<T>): ResourceState<T> {
const [state, setState] = useState<ResourceState<T>>({ status: "loading" });
useEffect(() => {
let cancelled = false;
fetcher()
.then((data) => {
if (!cancelled) setState({ status: "success", data });
})
.catch((err: unknown) => {
if (!cancelled) {
const error = err instanceof Error ? err : new Error(String(err));
setState({ status: "error", error });
}
});
return () => {
cancelled = true;
};
}, [fetcher]);
return state;
}
這支 Hook 展示四個觀念:第一,ResourceState<T> 是泛型聯合,三個分支各有不同的附加欄位;第二,isSuccess 與 isError 是 type guard,用「參數 is 型別」宣告;第三,useEffect 裡用 cancelled 旗標避免「元件已解除掛載、但 fetch 完成」造成的 state 更新;第四,catch 變數用 unknown 再做 instanceof 判斷,符合 strict 模式的要求。
呼叫端用起來很直覺——配合 type guard,TypeScript 會自動縮小型別:
// src/features/booking/useBookings.ts
// 呼叫端:用 type guard 收縮狀態
import { useResource, isSuccess, isError } from "../../hooks/useResource";
import type { Booking } from "../../types/booking";
async function fetchBookings(): Promise<Booking[]> {
const res = await fetch("/api/bookings");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export function useBookings() {
const state = useResource<Booking[]>(fetchBookings);
if (isSuccess(state)) {
// 這裡 state.data 是 Booking[]
return { kind: "ok" as const, bookings: state.data };
}
if (isError(state)) {
// 這裡 state.error 是 Error
return { kind: "err" as const, message: state.error.message };
}
// 剩下的就是 loading
return { kind: "loading" as const };
}
注意最後的 as const——它讓 TypeScript 把字串型別「窄化」成具體值("loading" 而不是 string),這樣後續 switch (result.kind) 時編譯器就能完整窮舉。這是 React 元件設計裡常見的小技巧,能讓程式碼「寫起來像樣板、跑起來極精準」。
常見錯誤與踩雷
第一次寫泛型與工具型別的程式,最常踩的雷有三個。第一個是「過度泛型化」。很多人第一次學到泛型很興奮,把所有函式都加型別參數,結果程式碼變得難讀。正確原則是「同一個函式或元件真的會被不同型別重用」才用泛型;如果只在一個地方用,直接寫具體型別更清楚。後端寫過 Java 的人對這個權衡應該很有感。
第二個是「忘了給泛型加約束」。沒有約束的型別參數等於「可以是任何東西」,你呼叫 T.length 或 T.map(...) 都會報錯。修法是用 extends 加上約束(型別必須至少有指定欄位、必須實作指定介面),讓 T 至少有你需要的能力。約束過度寬鬆或過度嚴格都會出問題:太寬鬆會失去型別保護、太嚴格會限制重用範圍。
第三個是「type guard 寫錯」。最常見的錯誤是「在 type guard 函式裡只檢查部分條件」。例如「當回傳 true 時是某型別」的函式必須真的只在那種情況回傳 true;如果你的函式在「應該回傳 false」的情況誤回 true,TypeScript 仍會相信你,把錯誤的型別當成正確的型別傳遞下去。這是 type guard 最危險的地方:型別系統不會幫你抓 runtime bug,只會相信你寫的「參數 is 型別」宣告。修法是在 type guard 裡加上充分的測試覆蓋率。
效能與實務提醒
工具型別本身在執行階段沒有成本——它們只是「型別運算子」,編譯後會被完全擦除。泛型也是一樣:useResource<Booking[]> 編譯後的 JavaScript 跟 useResource<User[]> 完全相同。所以你可以放心使用工具型別,不必擔心 bundle 大小。
真正的效能瓶頸往往出現在「泛型元件的 prop 比較」。當父元件每次 render 都建立新的物件或陣列傳給子元件時,子元件即使 props 內容相同,React 仍會認為「props 變了」而重新渲染。這個問題在泛型元件裡特別常見,因為泛型通常伴隨著「傳入整包資料」。修法是用 React.memo 包子元件、或在父元件用 useMemo 穩定參考。Day 13 與 Day 39 會展開效能調校。
另一個實務建議是「把工具型別的組合抽成 alias」。當你發現自己在不同檔案重複寫同一段 Omit 組合,就該抽成 BookingDraft。Day 16 會展開專案結構,到時候會建立一個 types/derived.ts 集中管理這些衍生型別。
最後談一下「as const vs as Type」。as Type 是強型別轉換(可能繞過編譯器保護),as const 是「把值當成不可變的字面量」。兩者完全不同:前者用在「我真的知道這個值的型別、編譯器推論錯了」;後者用在「我想要更精準的字面型別」。濫用 as Type 是 TypeScript 的反模式,會把型別系統保護全部關掉;as const 則是常見的安全手法。
條件型別與映射型別:進階泛型速覽
今天介紹的工具型別其實都是「條件型別」(conditional types)與「映射型別」(mapped types)的組合。理解這兩個觀念後,你就能自己寫出對應的客製化工具型別,不用總是靠 TypeScript 內建的清單。
條件型別的語法像三元運算子:T extends U ? X : Y,意義是「如果 T 可以指派給 U,結果是 X,否則是 Y」。下面是個簡單的例子——做一個「萃取 Promise 內部型別」的工具:
// src/utils/unwrap.ts
// 條件型別:如果 T 是 Promise<U> 就回傳 U,否則回傳 T 自己
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<number>; // number
const a: A = "hello";
console.log(a.toUpperCase());
// 輸出:HELLO
這個 Awaited 跟我們之後會用到的 ReturnType、Awaited<ReturnType<typeof fetch>> 是同一族觀念。後端寫過 Java Optional 的 map 與 flatMap 會覺得:條件型別就是「在型別層級做 map/flatMap」。
映射型別則是把一個物件型別「映射」成另一個:
// src/utils/mapped.ts
// 映射型別:把 T 所有欄位加上 readonly,自動產生新型別
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
type Booking = {
id: number;
customerName: string;
};
type ReadonlyBooking = MyReadonly<Booking>;
// 等同於 { readonly id: number; readonly customerName: string; }
keyof T 是「T 的所有鍵」,[K in keyof T] 是「對每個鍵做同樣的事」。這個寫法跟 JavaScript 的 Object.keys(t).map(...) 是同一個概念,只是搬到型別層級。理解這兩個觀念之後,TypeScript 內建的 Pick、Omit、Partial 對你來說就不再是「黑盒子」,而是「可以自己改寫的素材」。
本系列 Day 5 開始進入 React 元件設計,到時候 ComponentProps、ElementRef 這些「對 React 元件本身取型別」的工具會大量出現。它們都是建立在條件型別與映射型別之上。理解原理之後,看 .d.ts 檔案就不再那麼吃力了。
小結
今天我們把 TypeScript 推得更深了:泛型(型別參數、約束、預設)、收窄(typeof、in、instanceof、自訂 type guard)、工具型別(Pick、Omit、Partial、Required、Readonly、Record),並用一個 useResource Hook 把它們全部串起來。這個 Hook 會在 Day 6(useState)、Day 10(自訂 Hook)、Day 14(資料取得)反覆出現,是整個系列的「型別即契約」代表作。明天 Day 5 我們正式進入 React:元件、props 與 JSX——從「型別」跨進「畫面」,看 React 怎麼把剛剛學的泛型與型別簽名用在元件設計上。
結語
明天,我們會建立第一個真正「會被 React 渲染」的元件:函式元件怎麼寫、props 怎麼傳、JSX 怎麼編譯成 React.createElement、children 是什麼、為什麼 React 19 的元件可以回傳 Promise。Day 5 結束時,你應該能把今天學的 TypeScript 工具型別直接套到 React props 上,並能寫出可重用的元件骨架。接著 Day 6 進入 useState 與事件處理,正式讓畫面「活起來」。
延伸資源
- TypeScript Handbook〈Generics〉:
https://www.typescriptlang.org/docs/handbook/2/generics.html - TypeScript Handbook〈Type Narrowing〉:
https://www.typescriptlang.org/docs/handbook/2/narrowing.html - TypeScript Handbook〈Utility Types〉:
https://www.typescriptlang.org/docs/handbook/utility-types.html - React 官方〈TypeScript: Generic Components〉:
https://react.dev/learn/typescript#generic-components - Total TypeScript 教學(付費但品質極高):
https://www.totaltypescript.com/
留言
張貼留言