Writing

《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",
...
}
}
// terminal
npm 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.ts
declare 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;
}
// or
type Props = {
className: string;
};
export const Button = (props: Props) => {
return <button className={props.className}></button>;
};

interface & type

interfacetype 都是可以用來定義物件中的型別,但兩者還是有一些差別。

interface

同一個檔案內可以重複定義相同的 interface,TypeScript 會自動幫你合併,另外用 interface 也可以被延展,類似類 (class) 中的繼承。

// samefile.ts
interface 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 render
children: 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>
);
};
<Button
onClick={() => {}}
type="submit"
disabled
aria-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 獲取資料時,整個流程是這樣的:

  1. 元件初始化 → 狀態為空
  2. 發送 API 請求 → 資料載入中
  3. 收到回應 → 更新狀態並重新渲染
  4. 在步驟 1 和 2 期間,我們的狀態實際上是「尚未有資料」的,但如果沒有正確定義型別,TypeScript 會在編譯時報錯。

所以我們需要在型別定義中明確包含 undefinednull,告訴 TypeScript 這個狀態在某些時候可能沒有值:

type Data = {
id: number;
name: string;
}
const Comp = () => {
const [user, setUser] = useState<User | undefined>();
useEffect(() => {
fetchUser().then(setUser);
}, []);
// ✅ Best: Early return with type narrowing
if (!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: string
name: 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 type
const onClick = useCallback<string>(
(buttonName) => {
console.log(buttonName);
},
[]
);
// ✅ Explicit function type
const onClick = useCallback<(buttonName: string) => void>(
(buttonName) => {
console.log(buttonName);
},
[]
);

useMemo 則是為了避免重複執行昂貴的計算,所以只需要定義回傳型別

// ❌ Wrong! Returns function type
const autoGeneratedIds = useMemo<() => string[]>(() => {
return Array.from({ length: 100 }, () =>
Math.random().toString(36).substr(2, 9)
);
}, []);
// ✅ Explicit return type
const 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 | undefined
const id = useRef<string>();
const timer = useRef<NodeJS.Timeout>(); // For timer IDs
const count = useRef<number>(0); // With initial value

DOM 的 引用

大多數時候 useRef 會被用來引用 (reference) DOM 的元素,但是 React 是在 browser runtime 才開始建構 React Elmenet Tree 所以 useRef 會有未被定義的時候,這時候我們就需要向為 useState 處理異步操作時,預先定義 null

// ❌ Missing initial value
const ref = useRef<HTMLDivElement>();
return <div ref={ref} />; // Type error!
// ✅ Explicitly pass null
const 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 主要接收兩個參數 reducerinitialState 以及回傳 statedispatch。接下來我們來用簡單的計數器來解釋,這樣大家就可以更好的理解。

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 Comp
const [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 reusable
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
// ❌ Reusable but not type safe
function getFirst(arr: any[]): any {
return arr[0];
}
// ✅ Both type safe AND reusable
function 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 的變體可能有多個(primaryghost 等等):

type ButtonVariants = 'primary' | 'ghost'

上面的寫法就會把變體限制在 primaryghost 之間。如果我們要鬆綁限制,可能會這樣寫:

type ButtonVariants = 'primary' | 'ghost' | string

但這樣寫 TypeScript 自動補字功能就會失效,主要是因為 ButtonVariants 已經被鬆綁成任何 string 都可以了。當聯合型別中包含了 string 這個寬泛的型別時,TypeScript 的 IntelliSense 就會認為所有字串都是有效的,因此不會特別建議 primaryghost 這些具體的選項。

type ButtonVariants = 'primary' | 'ghost' | (string & {});

而上述寫法就可以讓 TypeScript 知道我們既想要保留預定義的選項建議,又想要允許自訂值。這個巧妙的寫法運用了 TypeScript 型別系統的特性:string & {} 在邏輯上等同於 string,因為任何字串都滿足空物件的條件。

然而,TypeScript 的自動補全機制會區別對待 stringstring & {}。當使用 string & {} 時,編輯器仍然會優先建議聯合型別中的 literal types(primaryghost),同時保持接受任何字串值的彈性。

上述情境可以用在很多次,但我們總不想要每次都在最後加上 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 components
type 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 case
console.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 Signature
const useLocalStorage = <T>(key: string): {
value: T | null;
setValue: (value: T) => void;
}
// Usage
const { 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 取得資料或處理用戶輸入時,資料的型別往往是 unknownany,比較安全的做法是在使用前資料前先驗證其結構和型別。

泛型 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);
}
// Usage
const 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 broad
function 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 types
function getValue(defaultValue: string): string; // Overload 1
function getValue(): string | undefined; // Overload 2
function getValue(defaultValue?: string): string | undefined { // Implementation
return defaultValue || (Math.random() > 0.5 ? "random" : undefined);
}
const definiteValue = getValue("hello"); // Type: string ✅
const maybeValue = getValue(); // Type: string | undefined ✅

這種模式在函式編程中也很常見,像是 curry 函式,我們通常不知道使用者會傳入多少個參數,但希望根據參數數量提供不同的回傳型別:

// Curry function with overloads
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): 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 types
const add = (x: number, y: number) => x + y;
const curriedAdd = curry(add); // Type: (a: number) => (b: number) => number
const addFive = curry(add, 5); // Type: (b: number) => number
const 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 和 age
const 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.DetailedHTMLProps
React.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 結合使用的方式!

如果您喜歡這篇文章,請點擊下方按鈕分享給更多人,這將是對筆者創作的最大支持和鼓勵。
Buy me a coffee