• 元件 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 把驗證與型別合一。