Writing

《React 101》React 与 TypeScript 使用攻略

文章發表於

TypeScript with React 完整指南

本篇文章是带大家复习使用 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;
}
// 或者
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 };

React.ReactNode

在现实开发中,很多时候我们会需要将组件通过 children 的方式传递到下层,这时候就必须为该参数定义类型,然而有时候传递的值并不一定是 React 元素,有可能是其他类型,像是一串文字或是 null,这时候 React.ReactNode 作为环传类型就非常有用,相比于 React.ReactElement 它有着更多的弹性。

interface Props {
children: React.ReactNode;
}
const Comp = ({ children }: Props) => {
return <div>{children}</div>;
};

如果从 @types/react index.d.ts 找,可以看到 ReactNode 所有类型的联合:

type ReactNode =
| ReactElement
| string
| number
| Iterable<ReactNode>
| ReactPortal
| boolean
| null
| undefined

💡 提示: JSX.Element 其实就是 React.ReactElement<any, any> 的别名,所以它的 props 与 type 默认都是 any

Best Practice Decision Tree

// 使用这个决策树:
interface Props {
// ✅ 一般的 React 可渲染内容
children: React.ReactNode;
// ✅ 特别需要元素对象时(较少见)
icon: React.ReactElement;
// ✅ 函数返回类型(JSX 表达式)
render: () => JSX.Element;
}

此外,事件监听也是常见的参数,例如 onClick 事件的类型就会是 React.MouseEventHandler<HTMLButtonElement>

interface ButtonProps {
children: React.ReactNode;
className?: string;
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 这样就会导致类型冲突

解决方式的方法就是通过 TypeScript 中的 Omit 将 React 定义的 onChange 类型剔除,再用联合的方式将自定义的 onChange 加上。

// ❌ 这会造成类型冲突:
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 该变量的类型,这时候类型标记就非常重要。

💡 什么时候要对 useState 用类型标记? 只要初始值是空数组、空对象、undefined 或是 null 就建议定义类型。

// ❌ 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. 收到回应 → 更新状态并重新渲染

在步骤 1 和 2 期间,我们的状态实际上是「尚未有资料」的,但如果没有正确定义类型,TypeScript 会在编译时报错。

所以我们需要在类型定义中明确包含 undefinednull,告诉 TypeScript 这个状态在某些时候可能没有值:

type User = {
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, // 拼写错误,但 TypeScript 不会报错
}));
// ✅ Now TypeScript catches the typo!
setState((currentState): User => ({
...currentState,
isPro: true, // TypeScript 会检查属性名是否正确
}));

useCallback & useMemo

useCallback 主要是避免重复渲染昂贵的函数,通过记忆化(memoization)来快取函数,只有在依赖项目改变时才会重新建立函数,而其接收函数作为参数,所以要定义输入以及输出类型:

// ❌ 错误!string 不是函数类型
const onClick = useCallback<string>(
(buttonName) => {
console.log(buttonName);
},
[]
);
// ✅ 明确的函数类型
const onClick = useCallback<(buttonName: string) => void>(
(buttonName) => {
console.log(buttonName);
},
[]
);

useMemo 则是为了避免重复执行昂贵的计算,所以只需要定义回传类型:

// ❌ 错误!返回函数类型
const autoGeneratedIds = useMemo<() => string[]>(() => {
return Array.from({ length: 100 }, () =>
Math.random().toString(36).substr(2, 9)
);
}, []);
// ✅ 明确的返回类型
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!
// ✅ 可以保存 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 Element Tree 所以 useRef 会有未被定义的时候,这时候我们就需要像为 useState 处理异步操作时,预先定义 null

// ❌ 缺少初始值
const ref = useRef<HTMLDivElement>();
return <div ref={ref} />; // Type error!
// ✅ 明确传递 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 Component
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 通常会有 errorsuccessloading 的状态,而判别联合就非常有用,这点会在之后再次提到。

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 组件通常会有多种形态 primarysecondary 以及 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