《React 101》React 與 TypeScript 使用攻略
- 文章發表於
本篇文章是帶大家複習使用 TypeScript 寫 React 的一些概念,無論是否有 TypeScript 的經驗都適合閱讀此文,希望這篇文章都可以幫助到大家。
入門
在 React 我們主要是透過 JSX (JavaScript XML) 來建構使用者介面,其只是 React 所提供的語法擴充,而最終你所寫的 JSX 會被轉譯成 React.createElement
,React 則會用它建構 React Element Tree.
const element = <div className="container">Hello TypeScript!</div>;在幕後,這會被編譯成:const element = React.createElement("div", { className: "container" }, "Hello TypeScript!");
為了讓 TypeScript 正確編譯 JSX 語法並支援 React 17+ 新 JSX Transform,我們會需要在
tsconfig.json
裡設定"jsx": "react-jsx"
- 安裝
@types/react
以及@types/react-dom
,這裡面提供了 React 和 ReactDOM 的 TypeScript 型別定義
如果不設置這個選項,TypeScript 就可能無法正確解析 JSX,或者會使用不兼容的編譯方式,導致編譯錯誤或運行時問題。
// tsconfig.json{"compilerOptions": {"jsx": "react-jsx",...}}// terminalnpm install --save-dev @types/react @types/react-dom
元件
我們可以建立一個 .tsx
檔案並開始寫第一個元件,此時你可能會想為什麼 <div>
或是放入的 props
怎麼不會被報錯,像是 id
或是 onChange
甚至還有自動補字功能。
export const MyComponent = () => {return (<div// How do I figure out what type id expects?id="My Components"// How do I figure out what type onChange expects?onChange={() => {}}/>);};
這些都是在 @types/react
中被定義好的,當你游標移動到 <div>
並 ctrl
+ 右鍵
,就可以看到 React 已經事先將 HTML 標籤預先定義在 index.d.ts
當中了。
// index.d.tsdeclare global {namespace JSX {interface IntrinsicElements {div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;// ... all HTML elements}}}
參數 (Props)
React 的特色不外乎就是將多個元件組合成頁面,其中 props
就扮演非常重要的角色,能夠讓子母元件進行資訊的傳遞,而在 TypeScript 我們可以透過 interface
或是 type
去定義 props
的型別,否則 TypeScript 在編譯的過程中就會報錯。
// ❌export const Button = (props: unknown) => {return <button className={props.className}></button>;// ^^^^^^^^^^^^^^^ TypeScript error!};// ✅interface Props {className: string;}// ortype Props = {className: string;};export const Button = (props: Props) => {return <button className={props.className}></button>;};
interface & type
interface
跟 type
都是可以用來定義物件中的型別,但兩者還是有一些差別。
interface
同一個檔案內可以重複定義相同的 interface
,TypeScript 會自動幫你合併,另外用 interface
也可以被延展,類似類 (class) 中的繼承。
// samefile.tsinterface User {name: string;}interface User {age: number;}// 自動合併後等同於:// interface User {// name: string;// age: number;// }
type
type
則通常用在聯合型別 (Union Types),交集型別 (Intersection Types),以及更複雜的型別運算
type Status = 'pending' | 'approved' | 'rejected';type ApiResponse<T> =| { status: 'loading' }| { status: 'success'; data: T }| { status: 'error'; error: string };
整理了一張決策表,讓我們更清楚知道什麼時候要用 type
以及什麼時候要用 interface
。
TODO: TS 決策樹
React.ReactNode
在現實開發中,很多時候我們會需要將元件透過 children
的方式傳遞到下層,這時候就必須為該參數定義型別,然而有時候傳遞的值並不一定是 React 元素,有可能是其他型別,像是一串文字或是 null
,這時候 React.ReactNode
作為環傳型別就非常有用,相比於 React.ReactElement
它有著更多的彈性。
interface Props {children: React.ReactNode;}const Comp = ({ children }: Props) => {return <div>{children}</div>;};
如果從 @type/React
index.d.ts 找,可以看到 ReactNode 所有型別的聯合
type ReactNode =| ReactElement| string| number| Iterable<ReactNode>| ReactPortal| boolean| null| undefined
Best Practice Decision Tree
// Use this decision tree:interface Props {// ✅ General content that React can renderchildren: React.ReactNode;// ✅ Specifically need element objects (rare)icon: React.ReactElement;// ✅ Function return types (JSX expressions)render: () => JSX.Element;}
此外,事件監聽也是常見的參數,例如 onClick
事件的型別就會是 React.MouseEventHandler<HTMLButtonElement>;
interface ButtonProps {// ...onClick: React.MouseEventHandler<HTMLButtonElement>;}export const Button = ({ children, className, onClick }: ButtonProps) => {return (<button onClick={onClick} className={className}>{children}</button>);};
這時候你可能就會想,連基本的 HTML 參數從上層傳下來都要逐一定義,這樣不就拖累了開發速度,而 React 就有提供 ComponentProps<T>
來解決這個問題!
import { ComponentProps } from "react";export const Button = ({className,...rest}: ComponentProps<"button">) => {return (<button {...rest} className={className}></button>);};<ButtononClick={() => {}}type="submit"disabledaria-label="Submit form"data-testid="submit-btn"// ... 其他 button 的參數/>
但需要注意的是需要確認傳遞的參數是否有衝突,舉例來說,現在我們要傳入自定義的 onChange
型別是 onChange: (value: string) => void
然而 React 中定義的 onChange: (value: string) => void
這樣就會導致型別衝突。
解決方式的方法就是透過 TypeScript 中的 Omit
將 React 定義的 onChange 型別剔除,在用聯合的方式將自定義的 onChange
加上。
❌ This creates a type conflict:type InputProps = ComponentProps<"input"> & {onChange: (value: string) => void};✅type InputProps = Omit<ComponentProps<"input">, "onChange"> & {onChange: (value: string) => void;};export const Input = (props: InputProps) => {return (<input{...props}onChange={(e) => {props.onChange(e.target.value); // Now we pass just the string}}/>);};
React Hooks
useState
型別標記
當在使用 useState
並且以空陣列作為初始值,這可能會導致 React 沒辦法像是用 infer primitive 的方式去 infer 該變數的型別,這時候型別標記就非常重要。
❌ TypeScript infers: never[]const [users, setUsers] = useState([]);✅type User = {id: number;address: string;};const [users, setUsers] = useState<User[]>([]);
處理未定義的狀態
在 React 開發中使用 fetch
獲取後端資料時,會遇到一個常見的 TypeScript 問題:資料在載入過程中處於未定義狀態。當我們從 API 獲取資料時,整個流程是這樣的:
- 元件初始化 → 狀態為空
- 發送 API 請求 → 資料載入中
- 收到回應 → 更新狀態並重新渲染
- 在步驟 1 和 2 期間,我們的狀態實際上是「尚未有資料」的,但如果沒有正確定義型別,TypeScript 會在編譯時報錯。
所以我們需要在型別定義中明確包含 undefined
或 null
,告訴 TypeScript 這個狀態在某些時候可能沒有值:
type Data = {id: number;name: string;}const Comp = () => {const [user, setUser] = useState<User | undefined>();useEffect(() => {fetchUser().then(setUser);}, []);// ✅ Best: Early return with type narrowingif (!user) {return <div>Loading...</div>;}// TypeScript now knows user is definitely User (not undefined)return <div>Welcome, {user.name}!</div>;};
型別檢查
當我們在更新狀態時,如果沒有正確的定義型別,可能就無法幫我們抓到潛在的錯字。TypeScript 會在你明確指定回傳型別時執行「多餘屬性檢查」(excess property checking)。若沒有加上型別註解,TypeScript 則允許額外的屬性存在。
type User = {id: stringname: string;isPro: boolean;}// ❌ Typo! Should be "isPro"setState((currentState) => ({...currentState,isPor: true,}));// ✅ Now TypeScript catches the typo!setState((currentState): User => ({...currentState,isPro: true,}));
useCallback & useMemo
useCallback
主要是避免重複渲染昂貴的函式,透過記憶化(memoization)來快取函式,只有在依賴項目改變時才會重新建立函式,而其接收函式作為參數,所以要定義輸入以及輸出型別
// ❌ Wrong! string is not a function typeconst onClick = useCallback<string>((buttonName) => {console.log(buttonName);},[]);// ✅ Explicit function typeconst onClick = useCallback<(buttonName: string) => void>((buttonName) => {console.log(buttonName);},[]);
useMemo
則是為了避免重複執行昂貴的計算,所以只需要定義回傳型別
// ❌ Wrong! Returns function typeconst autoGeneratedIds = useMemo<() => string[]>(() => {return Array.from({ length: 100 }, () =>Math.random().toString(36).substr(2, 9));}, []);// ✅ Explicit return typeconst autoGeneratedIds = useMemo<string[]>(() => {return Array.from({ length: 100 }, () =>Math.random().toString(36).substr(2, 9));}, []);
關鍵差異
useMemo<T>
其中T
= 回傳值的型別useCallback<T>
其中T
= 函式本身的型別
useRef
基本用法
useRef
需要明確的定義你所放入的型別,否則 TypeScript 會在編譯時報錯。
// ❌ TypeScript infers: useRef<undefined>const id = useRef();// id.current can only ever be undefined!
// ✅ Can hold string | undefinedconst id = useRef<string>();const timer = useRef<NodeJS.Timeout>(); // For timer IDsconst count = useRef<number>(0); // With initial value
DOM 的 引用
大多數時候 useRef
會被用來引用 (reference) DOM 的元素,但是 React 是在 browser runtime 才開始建構 React Elmenet Tree 所以 useRef
會有未被定義的時候,這時候我們就需要向為 useState
處理異步操作時,預先定義 null
。
// ❌ Missing initial valueconst ref = useRef<HTMLDivElement>();return <div ref={ref} />; // Type error!// ✅ Explicitly pass nullconst ref = useRef<HTMLDivElement>(null);return <div ref={ref} />;
唯讀行為
useRef
最後一個需要注意的地方也是許多開發者在使用時經常遇到 TypeScript 型別錯誤,useRef
實際上有三個不同的重載版本,每一個都有其特定的用途和行為特點:
第一種:可變引用(非空初始值)
// ✅ 正常運作!返回 MutableRefObject<string>const ref1 = useRef<string>("initial");ref1.current = "Hello";
當我們為 useRef 提供一個非空的初始值時,TypeScript 會將其識別為可變的引用物件。這種形式主要用於儲存可變的資料,例如計數器、定時器 ID 或其他需要在組件重新渲染之間保持狀態的值。
第二種:唯讀引用(null 初始值)
// ❌ 錯誤!返回 RefObject<string>(唯讀)const ref2 = useRef<string>(null);ref2.current = "Hello";
這是最常見也最容易產生困惑的用法。當初始值為 null
時,TypeScript 會假設這個 ref 是用於 DOM 元素,因此返回一個唯讀的 RefObject
。這個設計背後的邏輯是:DOM 引用應該由 React 自己管理,開發者不應該直接修改 current 屬性。
第三種:可能未定義的可變引用
// ✅ 正常運作!返回 MutableRefObject<string | undefined>const ref3 = useRef<string>();ref3.current = "Hello";
當我們不提供初始值時,TypeScript 會將其視為一個可變的引用,但類型會包含 undefined。這種形式適用於那些一開始沒有值,但後來會被賦值的場景,像是 setTimeout
等等。
useReducer
useReducer
主要是用來做比較複雜的狀態管理,這樣要使用它時所需要為狀態定義的型別就會比較多,而 useReducer
主要接收兩個參數 reducer
跟 initialState
以及回傳 state
跟 dispatch
。接下來我們來用簡單的計數器來解釋,這樣大家就可以更好的理解。
type State = {count: number;};type AddAction = {type: "add";add: number;};type SubtractAction = {type: "subtract";subtract: number;};type Action = AddAction | SubtractAction;const reducer = (state: State, action: Action) => {switch (action.type) {case "add":return { count: state.count + action.add };case "subtract":return { count: state.count - action.subtract };default:throw new Error();}};// In your React Compconst [state, dispatch] = useReducer(reducer, { count: 0 });
更多型別的概念
判別聯合 (Tagged Union)
在前面提到聯合型別 (Union Types) 是多個型別的組合,使用 |
運算子連接,不過,當型別之間有重疊時,TypeScript 需要某種方式來判斷當前值到底是哪一個型別。這時候就會用到具有共同判別屬性的聯合型別(Union types with a common discriminant property)。
type PlaygroundProps =| { useStackblitz: true; stackblitzId: string }| { useStackblitz?: false; codeSandboxId: string };function openPlayground(props: PlaygroundProps) {if (props.useStackblitz) {// TS 自動推斷 props 為 { useStackblitz: true; stackblitzId: string }console.log("Opening Stackblitz:", props.stackblitzId);} else {// TS 自動推斷 props 為 { useStackblitz?: false; codeSandboxId: string }console.log("Opening CodeSandbox:", props.codeSandboxId);}}
這在做處理 API 回傳時非常有用,因為 ApiResponse
通常會有 error
, success
或是 loading
的狀態,而判別聯合就非常有用,這點會在之後再次提到。
AllOrNothing Pattern
用 React 寫共用元件時,最常見的設計模式就是控制元件 (controll component) 還是非控制元件 (uncontrolled component),主要的差別就是是否會從上層元件傳入狀態去控制子元件 HTML 中的狀態。
type InputProps = (| { value: string; onChange: ChangeEventHandler }| { value?: undefined; onChange?: undefined }) & { label: string };
然而這樣寫會有型別安全問題,如果傳入的是 {value: "good", label: "I'm label"}
, 這樣 TypeScript 不會發現錯誤,因為 onChange?
是允許不存在的,這在之後的泛型的章節,會提到有更有效的解決方法。
型別自動推導
在日常開發中,我們常常需要在程式的執行邏輯(執行時值)與型別系統(TypeScript 型別)之間保持一致。否則,就會出現「程式碼更新了,但型別沒有更新」的問題。這時候,我們就可以利用型別自動推導(進階型別推導)來確保單一資料來源(Single Source of Truth)。
const buttonVariants = {primary: { className: "btn-primary", color: "white" },secondary: { className: "btn-secondary", color: "black" },ghost: { className: "btn-danger", color: "white" }} satisfies Record<string, ComponentProps<"button">>;type ButtonProps = {variant: keyof typeof buttonVariants;children: ReactNode;};
這在設計系統中特別有用,舉例來說 button 元件通常會有多種型態 primary
, secondary
以及 ghost
,此時因為業務需求需要新增 variant,使用上面的寫法 Variant
就會跟著 VARIANT_CLASSES
新增而自動更新,確保型別是從單一來源。
在 satisfies
出來之前,我們只有兩種方式讓物件符合某個型別:
// 型別註解 (annotation) - 這樣能保證 config 符合 Config,但是會丟掉字面量精度 (config.mode 會被推斷成 string,而不是 "dark")。const config: Config = { mode: "dark" };// 型別斷言 (assertion) - 這樣能保留 "dark",但 TypeScript 不會驗證物件是否真的符合 Config。const config = { mode: "dark" } as Config;// 檢查物件是否符合 Config(安全)以及 保留物件原始的字面量精度(精確)const config = { mode: "dark" } satisfies Config;
泛型 (Generics)
泛型 (Generics) 是強型別語言中常見的語法,在 TypeScript 中,泛型主要可以讓型別建立時更容易被復用並保持型別安全。沒有泛型你必須在型別安全性(使用具體型別)與程式碼復用性(any
)之間做抉擇。
// ❌ Type safe but not reusablefunction getFirstString(arr: string[]): string | undefined {return arr[0];}function getFirstNumber(arr: number[]): number | undefined {return arr[0];}// ❌ Reusable but not type safefunction getFirst(arr: any[]): any {return arr[0];}// ✅ Both type safe AND reusablefunction getFirst<T>(arr: T[]): T | undefined {return arr[0];}
Syntax
泛型通常我們會使用 T
代表,而這個 T
可能為任何合法的 TypeScript 型別。以下面的 identity
函式來舉例,這在 functional programming 中是常見的函式,它的作用是直接回傳輸入的值。
function identity<T>(value: T): T {return value;}identity<number>(1) // 1, 但通常會直接讓 ts 幫我們做 infer identity([1, 2, 3])
上面使單一泛型的例子,而如果同時有多個泛型則可以用以下寫法
interface KeyValuePair<K, V> {key: K;value: V;}function createPair<K, V>(key: K, value: V): KeyValuePair<K, V> {return { key, value };}const stringNumberPair = createPair("age", 25);// Type: KeyValuePair<string, number>
Type Helper
前面有提到聯合型別,但有時候除了定義的聯合型別也會需要鬆綁,讓使用者自訂義。舉例來說,button 的變體可能有多個(primary
、ghost
等等):
type ButtonVariants = 'primary' | 'ghost'
上面的寫法就會把變體限制在 primary
與 ghost
之間。如果我們要鬆綁限制,可能會這樣寫:
type ButtonVariants = 'primary' | 'ghost' | string
但這樣寫 TypeScript 自動補字功能就會失效,主要是因為 ButtonVariants
已經被鬆綁成任何 string
都可以了。當聯合型別中包含了 string
這個寬泛的型別時,TypeScript 的 IntelliSense 就會認為所有字串都是有效的,因此不會特別建議 primary
或 ghost
這些具體的選項。
type ButtonVariants = 'primary' | 'ghost' | (string & {});
而上述寫法就可以讓 TypeScript 知道我們既想要保留預定義的選項建議,又想要允許自訂值。這個巧妙的寫法運用了 TypeScript 型別系統的特性:string & {}
在邏輯上等同於 string
,因為任何字串都滿足空物件的條件。
然而,TypeScript 的自動補全機制會區別對待 string
和 string & {}
。當使用 string & {}
時,編輯器仍然會優先建議聯合型別中的 literal types(primary
和 ghost
),同時保持接受任何字串值的彈性。
上述情境可以用在很多次,但我們總不想要每次都在最後加上 string & {}
,這時候就可以透過泛型建立一個可復用 Type Helper
type LooseAutocomplete<T> = T | (string & {});type LooseIcon = LooseAutocomplete<"home" | "settings" | "about">;type LooseButtonVariant = LooseAutocomplete<"primary" | "ghost">;
AllOrNothing
上面提到了用 React 寫共用元件時,最常見的設計模式就是控制元件 (controll component) 還是非控制元件 (uncontrolled component),而當時我們是用聯合型別去定義,但先前的寫法造成 TypeScript 無法有效的辨別可能的寫法錯誤。
此時就可以用泛型建立一個 Type Helper
type AllOrNothing<T extends Record<string, any>> = T | ToUndefinedObject<T>;type ToUndefinedObject<T extends Record<string, any>> = Partial<Record<keyof T, undefined>>;// Usage: Controlled vs Uncontrolled componentstype InputProps = AllOrNothing<{value: string;onChange: (value: string) => void;}> & {label: string;};// ✅ Fully controlled<Input label="Name" value={name} onChange={setName} />// ✅ Fully uncontrolled<Input label="Name" />// ❌ Partially controlled (TypeScript error)<Input label="Name" value={name} />
這個 AllOrNothing Helper 解決了一個重要的問題:確保開發者在使用 React 元件時,要嘛完全採用控制模式,要嘛完全採用非控制模式,避免了「半控制」狀態的錯誤。ToUndefinedObject<T>
的作用是將傳入的型別 T
轉換成一個所有屬性都是可選且值為 undefined
的物件型別。當與原始型別 T
做聯合時,就形成了「全有或全無」的限制。
約束模式
處理 API 回傳的資料是日常開發中常見的狀況,前面在聯合型別有提到類似的概念,而當時我們是這樣處理,主要問題是錯誤型別會變成 any
,失去了型別安全性。我們無法知道錯誤物件的具體結構,也沒有提示,這在處理不同類型的錯誤時會造成困擾。
// 傳統寫法type ApiResponse<T> =| { success: true; data: T }| { success: false; error: any };// 使用時的問題function handleResponse<T>(response: ApiResponse<T>) {// ✅ 有型別提示if (response.success) {// TypeScript 知道這裡是 success caseconsole.log(response.data);} else {// ❌ error 的型別是 any,失去了型別安全console.log(response.error);}}
下面寫法的最大好處就是 TypeScript 能夠根據輸入的型別自動判斷回傳的結構。當後端回傳是 error 時,型別系統知道這會是一個失敗回傳;反之,則會是成功。這樣編輯器就能提供正確的提示和型別檢查。
type ApiResponse<T extends object> = T extends { error: any }? { success: false; error: T['error'] }: { success: true; data: T };type UserResponse = ApiResponse<{ name: string; address: string }>;// 結果:{ success: true; data: { name: string; age: string } }type ErrorResponse = ApiResponse<{ error: string }>;// 結果:{ success: false; error: string }
React 與泛型
Generic Hook
有了泛型就可以用它搭配實踐通用的 React Hook,以 useLocalStorage
來舉例,實作的功能就是拿跟更新值,這時候 Type Signature 就可以定義成:
// Type Signatureconst useLocalStorage = <T>(key: string): {value: T | null;setValue: (value: T) => void;}// Usageconst { value: user, setValue: setUser } = useLocalStorage<User>("user");
這樣的設計帶來了便利性和型別安全。首先,useLocalStorage
變成了一個完全可復用的 Hook,不管你想要儲存什麼類型的資料都可以使用同一個實作。
更重要的是型別安全的保證。當你指定 useLocalStorage<User>("user")
時,TypeScript 就知道 user 變數的型別是 User
| null,而 setUser
函式只接受 User
型別的參數。這樣在使用時就不會發生型別錯誤,比如意外傳入錯誤格式的資料到 setUser
中。
export const useLocalStorage = <T>(key: string): {value: T | null;setValue: (value: T) => void;clearValue: () => void;} => {const [value, setValue] = useState<T | null>(null);useEffect(() => {const stored = localStorage.getItem(key);if (stored) {setValue(JSON.parse(stored));}}, [key]);const handleSetValue = (newValue: T) => {setValue(newValue);localStorage.setItem(key, JSON.stringify(newValue));};const clearValue = () => {setValue(null);localStorage.removeItem(key);};return { value, setValue: handleSetValue };};
Generic Function Component
renderSomething
是在 React 常見的渲染寫法,下面以 Table
元件來舉例
interface TableProps<T> {data: T[];renderRow: (item: T, index: number) => ReactNode;keyExtractor: (item: T) => string | number;}export const Table = <T,>({ data, renderRow, keyExtractor }: TableProps<T>) => {return (<table><tbody>{data.map((item, index) => (<tr key={keyExtractor(item)}>{renderRow(item, index)}</tr>))}</tbody></table>);};
當使用 Table
元件時,TypeScript 會根據 data
陣列的型別自動推斷出 T
,然後確保 renderRow
函式的第一個參數和 keyExtractor
函式的參數都是正確的型別。
Generic Type Guards
當從 API 取得資料或處理用戶輸入時,資料的型別往往是 unknown
或 any
,比較安全的做法是在使用前資料前先驗證其結構和型別。
泛型 T
讓這個函式能夠適用於任何型別,而 typeGuard
參數接受一個型別守衛函式,負責驗證單個元素是否符合預期型別。函式的回傳型別 value is T[]
告訴 TypeScript,如果這個函式回傳 true
,那麼 value
就可以被當作 T[]
型別來使用。
function isArrayOfType<T>(value: unknown,typeGuard: (item: unknown) => item is T): value is T[] {return Array.isArray(value) && value.every(typeGuard);}// Usageconst isUser = (obj: unknown): obj is User => {return typeof obj === 'object' && obj !== null && 'name' in obj;};if (isArrayOfType(data, isUser)) {// data is now typed as User[]data.forEach(user => console.log(user.name));}
Advanced Concepts
Tuple Return Types
當建立自訂 Hook 來模仿 React 內建的 Hook(如 useState)時,開發者經常會遇到一個基本的 TypeScript 挑戰:型別擴展(type widening)。這是 React TypeScript 開發中最常見的挑戰之一。
理解型別擴展
型別擴展是 TypeScript 的預設行為,它會讓型別變得更通用以「幫助」開發者。然而,這在 Hook 開發中往往適得其反:
// 我們想要:一個 tuple [string, Dispatch<SetStateAction<string>>]// 但得到了:一個聯合陣列 (string | Dispatch<SetStateAction<string>>)[]export const useId = (defaultId: string) => {const [id, setId] = useState(defaultId);// id: string// setId: Dispatch<SetStateAction<string>>return [id, setId];// TypeScript 認為:「這是一個可變的陣列,可能會改變」// 推斷為:(string | Dispatch<SetStateAction<string>>)[]};const [id, setId] = useId("1");// 問題:id 和 setId 都有型別:string | Dispatch<SetStateAction<string>>// 這意味著你無法安全地使用任何一個!// 這些都會是錯誤:id.toUpperCase(); // ❌ 錯誤:Property 'toUpperCase' does not exist on type 'Dispatch<SetStateAction<string>>'setId("new-id"); // ❌ 錯誤:This expression is not callable
其主因是 TypeScript 的型別推斷系統設計得很保守。當它看到陣列字面量時,會假設陣列是可變的、靈活的,並且為了安全起見,寧可過於通用也不要過於具體。然而,對於 Hook 的回傳值,我們想要的是不可變的、有序的、型別化的 tuple,而不是靈活的陣列。
最直接的解決方案是明確告訴 TypeScript 我們想要什麼:
export const useId = (defaultId: string): [string, React.Dispatch<React.SetStateAction<string>>] => {const [id, setId] = useState(defaultId);return [id, setId];};// 現在使用時就有正確的型別const [id, setId] = useId("test");// id: string (不是 string | Dispatch)// setId: Dispatch<SetStateAction<string>> (不是 string | Dispatch)
as const
斷言是 TypeScript 表達「保持確切結構」的方式:
export const useId = (defaultId: string) => {const [id, setId] = useState(defaultId);return [id, setId] as const;};
as const
斷言從根本上改變了 TypeScript 推斷型別的方式。它告訴 TypeScript 將值視為不可變的字面量,而不是可變的型別。對於 Hook 來說,這完美地解決了型別擴展的問題,讓我們獲得精確的 tuple 型別,同時保持程式碼簡潔。
React Hook - useContext
React 的 Context API 在跨元件共享狀態方面很強大,但在 TypeScript 中卻帶來了重大挑戰。傳統的模式往往導致執行時錯誤和糟糕的開發者體驗:
// ❌ 傳統的問題模式const UserContext = React.createContext(null);const useUser = () => {const user = useContext(UserContext);// 問題 1:user 在執行時期可能是 null// 問題 2:沒有關於 user 應該包含什麼的型別資訊if (!user) {throw new Error("useUser must be used within UserProvider");}return user; // TypeScript 不知道這是什麼};
這種方法有幾個關鍵缺陷:容易忘記 null
檢查導致執行時期錯誤、沒有型別資訊或自動補全的糟糕開發體驗、新增新 context 需要大量樣板程式碼,以及難以模擬和測試。
泛型解決方案深度解析
我們的目標是建立一個工具函式,透過設計消除 null 檢查、在整個流程中保持完整的型別資訊、提供良好的開發體驗,並且可重用於任何資料型別:
const createRequiredContext = <T,>() => {const context = React.createContext<T | null>(null);const useContext = (): T => {const contextValue = React.useContext(context);if (contextValue === null) {throw new Error("Context value is null");}return contextValue;};return [useContext, context.Provider] as const;};
讓我們追蹤型別資訊是如何流動的。當你呼叫 createRequiredContext<User>()
時,TypeScript 會將泛型 T
替換為 User
型別,建立一個 React.createContext<User | null>(null)
,然後 hook 函式的型別變為 () => User,Provider
的型別為 React.Provider<User | null>
。
Function Overloads
Function Overloads 可以讓我們在同一個函式擁有多個型別簽章(Type Signature),TypeScript 會根據傳入的參數提供不同的回傳型別。
// ❌ Without overloads - too broadfunction getValue(defaultValue?: string): string | undefined {return defaultValue || (Math.random() > 0.5 ? "random" : undefined);}const definiteValue = getValue("hello"); // Type: string | undefined (but we know it's string!)const maybeValue = getValue(); // Type: string | undefined (correct)// ❌ Unnecessary check// We have to do unnecessary null checks:if (definiteValue) {console.log(definiteValue.toUpperCase());}
使用 Function overloads 就可以解決上方所遇到的問題:
// ✅ With overloads - precise typesfunction getValue(defaultValue: string): string; // Overload 1function getValue(): string | undefined; // Overload 2function getValue(defaultValue?: string): string | undefined { // Implementationreturn defaultValue || (Math.random() > 0.5 ? "random" : undefined);}const definiteValue = getValue("hello"); // Type: string ✅const maybeValue = getValue(); // Type: string | undefined ✅
這種模式在函式編程中也很常見,像是 curry
函式,我們通常不知道使用者會傳入多少個參數,但希望根據參數數量提供不同的回傳型別:
// Curry function with overloadsfunction curry<A, B, C>(fn: (a: A, b: B) => C): (a: A) => (b: B) => C;function curry<A, B, C>(fn: (a: A, b: B) => C, a: A): (b: B) => C;function curry<A, B, C>(fn: (a: A, b: B) => C, a: A, b: B): C;function curry<A, B, C>(fn: (a: A, b: B) => C, a?: A, b?: B): any {if (arguments.length === 1) return (a: A) => curry(fn, a);if (arguments.length === 2) return (b: B) => fn(a!, b);return fn(a!, b!);}// Usage with precise typesconst add = (x: number, y: number) => x + y;const curriedAdd = curry(add); // Type: (a: number) => (b: number) => numberconst addFive = curry(add, 5); // Type: (b: number) => numberconst result = curry(add, 5, 3); // Type: number
Function Overloads 的主要優勢在於提供更精確的型別推斷,讓開發者避免不必要的型別檢查和型別斷言。除了增加了可讀性,也讓 TypeScript 的提示更加準確,減少了潛在的執行時期錯誤。特別是在設計 library 或工具函式時,Function Overloads 能夠提供更好的開發者體驗,讓使用者根據不同的參數組合獲得最適合的型別回傳。
More Deep Dive
Global Namespace and Declaration Merging
Declaration Merging 是 TypeScript 允許多個宣告對同一個實體做出貢獻的方式。這個特性在擴展現有型別定義和整合第三方函式庫時特別有用:
// 這些宣告會合併在一起:interface User {name: string;}interface User {age: number;}// 結果:User 同時擁有 name 和 ageconst user: User = { name: "John", age: 30 }; // ✅
Declaration Merging 的核心概念是 TypeScript 會自動將相同名稱的介面定義合併成一個更完整的型別。這不是覆蓋或替換,而是累加的過程。當你在不同的檔案或不同的程式碼區塊中定義相同名稱的介面時,TypeScript 會智能地將所有屬性組合起來。
全域命名空間擴增
全域命名空間擴增讓我們可以擴展 React 的內建型別,添加自訂的介面和屬性:
// 擴展全域 React 命名空間declare global {namespace React {// 添加自訂介面interface MyCustomHook<T> {data: T;loading: boolean;error?: string;}// 擴展現有介面interface HTMLAttributes<T> {'data-testid'?: string;'data-analytics'?: string;}// 添加自訂元件型別interface CustomComponents {'design-button': React.DetailedHTMLPropsReact.ButtonHTMLAttributes<HTMLButtonElement> & {variant: 'primary' | 'secondary';},HTMLButtonElement>;}}}
透過這種方式,我們可以為整個專案添加一致的型別定義,讓所有開發者都能享受到相同的型別安全和智能提示。特別是在大型專案中,這種全域擴增能確保團隊成員使用統一的 API 和屬性命名。
JSX 命名空間擴展
JSX 命名空間擴展讓我們可以為自訂元素和 Web Components 添加型別定義:
declare global {namespace JSX {interface IntrinsicElements {// Web Components'my-custom-element': {customProp: string;onCustomEvent?: (event: CustomEvent) => void;};// 第三方函式庫元素'chart-component': {data: number[];type: 'line' | 'bar' | 'pie';};}}}// 現在這些都能正常使用並具備完整的型別安全:<my-custom-element customProp="value" onCustomEvent={handler} /><chart-component data={[1, 2, 3]} type="line" />
這種擴展特別適用於使用 Web Components 或整合第三方 UI 函式庫的場景。透過型別定義,開發者可以在使用這些自訂元素時獲得與原生 HTML 元素相同的開發體驗。
小結
希望這篇文章可以幫助大家更快地掌握 React 與 TypeScript 結合使用的方式!