元件 props、事件處理器、hooks 的型別該怎麼寫、怎麼查?
怎麼寫一個泛型元件?什麼時候需要?
怎麼用型別表達「這兩個 props 只能擇一」這種規則?
公司 UI 元件常見的 是在做什麼?
API 層的型別怎麼組織?型別和執行期驗證的分工是什麼?
第 6 章建立了型別系統的模型,這一章把它接到 React 上—— 目標是讓你讀懂(並寫出)公司程式碼裡那些型別技巧的「為什麼」。
Props 的型別:元件的公開合約
interface UserCardProps {
user: User; // 必填
showEmail?: boolean; // 可選:呼叫端可以不管它
onSelect?: (userId: string) => void;
children?: React.ReactNode; // 「任何可以渲染的東西」
}
function UserCard({ user, showEmail = false, onSelect, children }: UserCardProps) {
// 可選 props 的預設值在解構時給,取代已棄用的 defaultProps
...
}
幾個值得說「為什麼」的點:
props 介面就是文件。第 5 章說「介面誠實」,落實方式就是把規則寫進型別: 必填/可選、回呼的參數是什麼,IDE 會在呼叫端即時執行這份文件。
的型別用 ——它是「任何合法渲染內容」的聯集 (元素、字串、數字、陣列、…)。 另一個常見選項 窄得多(只接受 JSX 元素, 字串都不行),用在「只能塞元件」的插槽。分不清時:容器用 ,九成情況正確。
回呼的參數用領域資料()而不是 React 事件—— 呼應第 5 章:洩漏越少,元件越可重用。
事件型別:用 IDE 查,不用背
需要碰原生事件(拿 、)時才會遇到事件型別。 不用背——在 JSX 上寫 inline handler,滑鼠停在 上,IDE 直接告訴你答案, 把它抄出來即可。高頻的三個:
| 場景 | 型別 |
|---|---|
| 輸入框 | |
| 按鈕 | |
| 表單 |
讀法本身是複習: 是泛型—— 「change 事件,發生在 input 元素上」, 所以 的型別精確到 , 才拿得到。泛型在這裡保留的正是「事件與元素種類」的關係。
useState 與 useRef 的型別
多數時候靠推斷就好( 自然是 )。 需要手動標註的訊號只有一個:初始值無法代表完整的型別。最典型的兩種:
// 1. 初始值是 null,但之後會裝 User —— 不標註的話型別被推斷成 null
const [user, setUser] = useState<User | null>(null);
// 2. 初始值是空陣列 —— 不標註會推斷成 never[],之後什麼都塞不進去
const [items, setItems] = useState<Item[]>([]);
這個聯集不是累贅,是誠實: 「還沒載入」是真實存在的狀態,型別把它寫出來之後, 每個使用 的地方都被迫先處理 —— 於是「忘記處理載入中」從線上錯誤變成紅線。 (進一步的版本你已經在第 5、6 章看過:多狀態時直接用標記聯集 。)
的兩種簽名對應第 4 章的兩種用途:
const inputRef = useRef<HTMLInputElement>(null); // DOM ref:交給 ref 屬性
const timerId = useRef<number | null>(null); // 可變盒子:自己讀寫 current
泛型元件:讓「一份 UI 邏輯」適用所有資料型別
第 6 章的泛型函式概念,原封不動搬到元件上。 需求場景:公司裡一定有的通用列表——載入、空狀態、逐項渲染, 邏輯對 、 完全相同,只差「一項長怎樣」:
interface ListProps<T> {
items: T[];
keyOf: (item: T) => string; // 身分函式(回想第 3 章的 key)
renderItem: (item: T) => React.ReactNode; // 每一項的長相交給呼叫端
empty?: React.ReactNode;
}
function List<T>({ items, keyOf, renderItem, empty }: ListProps<T>) {
if (items.length === 0) return <>{empty ?? '沒有資料'}</>;
return <ul>{items.map((it) => <li key={keyOf(it)}>{renderItem(it)}</li>)}</ul>;
}
// 呼叫端:T 由 items 推斷為 User,於是 renderItem 的參數自動是 User
<List
items={users}
keyOf={(u) => u.id}
renderItem={(u) => <UserCard user={u} />} // u: User,全程有補全
/>
注意型別在其中鎖住的關係:、、 三個 props 被同一個 綁在一起—— 傳 進來, 就不可能收到別的型別。 沒有泛型的話,這三者只能各自宣告成 系列,關係就斷了。 這個「插槽+泛型」組合( 這種 props 叫 render prop) 是元件庫 Table、Select、Autocomplete 的標準骨架,公司程式碼裡一定找得到。
在 裡寫箭頭函式版的泛型會撞到語法歧義: 的 會被當成 JSX 標籤而報錯。 解法:加個逗號 ,或改用 宣告(泛型元件慣用後者)。 知道這個,就不會在第一次寫泛型元件時卡半小時。
用標記聯集設計 props:把使用規則寫進型別
第 5 章遺留了一個問題:「兩個 props 只能擇一」怎麼表達? 場景: 既可以是真按鈕(要 ), 也可以是連結的樣子(要 ),但不能兩個都給。 用可選欄位寫 的話, 「都給」「都不給」這兩種錯誤用法照樣通過編譯。標記聯集登場:
type ButtonProps = { tone?: 'neutral' | 'danger'; children: React.ReactNode } & (
| { as: 'button'; onClick: () => void; href?: never }
| { as: 'link'; href: string; onClick?: never }
);
<Button as="button" onClick={save}>儲存</Button> // OK
<Button as="link" href="/docs">文件</Button> // OK
<Button as="link" onClick={save} href="/x">壞</Button> // 編譯錯誤!
拆解:共用欄位放交集()的左邊; 互斥的部分做成以 為標記的聯集; 是慣用技——「這個欄位在這個變體裡不准出現」 ( 是空集合,你給任何值都塞不進去)。 使用端一寫 ,IDE 的補全立刻切換成該變體的欄位。 元件的使用規則不再寫在註解或口頭交接裡,而是編譯器代為執法。
包裝原生元素:ComponentPropsWithoutRef
公司 UI 庫的 、 幾乎都要「支援原生元素的所有屬性」 (、、…上百個)。手抄是不可能的, 標準做法是直接繼承原生元素的 props 型別:
type ButtonProps = React.ComponentPropsWithoutRef<'button'> & {
tone?: 'neutral' | 'danger'; // 再交集上自己的擴充
isLoading?: boolean;
};
function Button({ tone = 'neutral', isLoading, children, ...rest }: ButtonProps) {
return (
<button className={toneClass[tone]} disabled={isLoading} {...rest}>
{isLoading ? <Spinner /> : children}
</button>
);
}
讀作 「原生 能接受的全部 props(不含 ref)」。 搭配 轉發,這個元件自動支援所有原生屬性與未來新增的屬性, 型別與行為一次到位——這就是你在公司元件庫裡看到這個模式的原因。 (對應地, 可以取出任何元件的 props 型別, 在「包裝第三方元件」時同樣好用。)
自訂 hook 的回傳型別
自訂 hook 是普通函式,但有一個專屬陷阱——回傳「陣列對」的時候:
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const;
// 沒有 as const:推斷成 (boolean | (() => void))[] —— 順序與長度都丟了
// 有 as const:readonly [boolean, () => void] —— 精確的 tuple
}
規則:回傳兩個以內、順序自明的值,用 tuple + (模仿 的手感,呼叫端可自由命名); 回傳三個以上或語意需要名字的,改回傳物件 (如第 5 章的 回傳 )。
API 層:型別的單一來源與執行期的守門員
把前面所有工具組裝成公司專案裡最重要的一層。原則有三條:
(一)領域型別集中定義、單點維護。 、 這些型別放在共用的 (或按領域拆檔), 元件、hook、API 全部 import 同一份——後端改欄位時,改這一處, 編譯器把所有受影響的位置標紅給你(重構安全網的日常用法)。
(二)用泛型寫一次請求邏輯。
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, init);
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json() as Promise<T>;
}
// 端點層:每個函式就一行,型別關係全部自動流動
export const fetchUser = (id: string) => api<User>(`/users/${id}`);
export const searchUsers = (q: string) => api<Paginated<User>>(`/users?q=${q}`);
(三)記住 是承諾,不是驗證。 上面 那行就是第 6 章講的「跨系統邊界」—— 型別在這裡是宣告出來的,不是檢查出來的。 對格式穩定的內部 API,多數團隊接受這個信任; 對關鍵路徑或第三方 API,用 zod 這類函式庫把「驗證」與「型別」合一:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>; // 型別直接從 schema 推導,單一來源
const data = UserSchema.parse(await res.json());
// 通過 = data: User(貨真價實);不通過 = 立刻在邊界丟出清楚的錯誤,
// 而不是讓髒資料流進深處,在某個元件裡以 undefined 錯誤的形式引爆
打開公司專案的 看 是否為 。 它裡面最有價值的是 :關掉時, / 可以塞進任何型別, 「 可能是 null」這種資訊型別系統根本表達不出來—— 本章一半的技巧會失效。新專案一律開 ; 舊專案若沒開,讀碼時要自行提高警覺(型別寫著 ,實際可能是 )。
props 介面是元件的公開合約:children 用 ,回呼參數用領域資料;預設值在解構時給。
事件型別用 IDE hover 查; 的泛型參數保留了「事件×元素」的關係。
只在「初始值不能代表全型別」時手動標註(、)。
泛型元件用一個 把 // 綁定成一致的整體。
標記聯集+ 欄位可表達「擇一」的 props 規則; 讓包裝元件繼承原生屬性。
hook 回傳 tuple 要 ;三個以上的值改回傳物件。
領域型別單一來源;泛型 fetcher 讓型別自動流動;邊界資料用 zod 把驗證與型別合一。