引言
React 的类型安全不只是「给 props 标个类型」——它覆盖组件 API 契约(props 怎么写、children 允许多宽)、状态机(页面有哪几种状态、各状态有什么数据)、事件与表单(onChange 的参数类型)、Context(上下文的值与默认值),以及最关键的全栈层面——前端组件与后端 API 的契约(请求响应模型两边一致,改一处报错全链条)。
本文系统讲 React 全栈类型安全:先讲 Props 与 Children 的类型化与泛型组件,再讲事件/表单/状态(useState/useReducer)、Context 与 Hooks;接着重点讲前后端共享类型与 API 契约,用判别联合建模 UI 状态机,最后排掉常见的 React 类型陷阱。
前置:/typescript/(TS 基础)、/typescript-advanced-types/(类型运算)、/typescript-api-type-generation/(API 类型生成)、[[frontend]](React 基础)。
目录
- 1. Props 与 Children 类型化
- 2. 泛型组件:复用的类型安全
- 3. 事件与表单类型
- 4. useState 与 useReducer 状态机
- 5. Context 与自定义 Hooks 类型
- 6. 前后端共享类型与 API 契约
- 7. 判别联合驱动 UI 状态
- 8. 常见 React 类型陷阱
- 9. 全栈类型安全架构
- 10. 速查表
- 延伸阅读
1. Props 与 Children 类型化
1.1 Props 基础
type UserCardProps = {
user: User
onSelect: (user: User) => void // 回调类型化
variant?: 'default' | 'compact' // 字面量联合限制取值
}
export function UserCard({ user, onSelect, variant = 'default' }: UserCardProps) {
return <button onClick={() => onSelect(user)} className={variant}>
{user.name}
</button>
}
1.2 Children 的三种类型
// 1. ReactNode:任意可渲染内容(最宽)
import type { ReactNode } from 'react'
type CardProps = { children: ReactNode }
// 2. 特定类型:限制 children 必须是某个组件
type MenuProps = { children: ReactElement<MenuItemProps>[] }
// 3. 函数子组件(Render Props)
type ListProps<T> = { items: T[]; render: (item: T) => ReactNode }
1.3 组件 API 的类型化原则
1. 必填 vs 可选:默认值给可选 + 解构默认
2. 用字面量联合限制「枚举型」props(variant/size)
3. 事件回调带完整参数类型(不只 any)
4. 只读:props 本身不可变(React 保证,TS 也标记)
一句话总结:Props 类型化 = 必填/可选 + 字面量联合 + 回调带参数类型;children 按「多宽」选择 ReactNode / 特定元素 / render props。
2. 泛型组件:复用的类型安全
2.1 泛型 List
// 泛型组件:列表项类型由调用方决定
type ListProps<T> = {
items: T[]
keyOf: (item: T) => string
render: (item: T) => ReactNode
}
export function List<T>({ items, keyOf, render }: ListProps<T>) {
return <ul>{items.map(item => <li key={keyOf(item)}>{render(item)}</li>)}</ul>
}
// 使用:T 自动推断
<List items={users} keyOf={u => u.id} render={u => <span>{u.name}</span>} />
2.2 泛型 with 约束
type HasId = { id: string }
// 约束 T 必须带 id
export function EntityList<T extends HasId>({ items }: { items: T[] }) {
return <ul>{items.map(i => <li key={i.id}>{JSON.stringify(i)}</li>)}</ul>
}
2.3 泛型 ref 与 forwardRef
import { forwardRef, type Ref } from 'react'
// 泛型 ref:外部拿到正确的实例类型
const FancyInput = forwardRef<HTMLInputElement, FancyInputProps>(
function FancyInput(props, ref) {
return <input ref={ref} {...props} />
}
)
// 使用:const ref = useRef<HTMLInputElement>(null)
一句话总结:泛型组件让「列表/选择器/表格」这类复用组件保持类型安全——T 自动推断、extends 约束能力边界;ref 也用泛型限定实例类型。
3. 事件与表单类型
3.1 事件处理器类型
import type { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from 'react'
// 不用手写,利用组件元素的推断
<input onChange={e => {
// e 自动推断为 ChangeEvent<HTMLInputElement>
const value: string = e.target.value
}} />
// 显式标注的场景
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') submit()
}
3.2 受控表单的类型安全
type FormState = { email: string; password: string; remember: boolean }
const [form, setForm] = useState<FormState>({ email: '', password: '', remember: false })
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm(prev => ({ ...prev, [key]: value }))
}
// K extends keyof → update('email', 123) 报错(值类型不匹配)
3.3 表单校验的类型化
type Errors<T> = Partial<Record<keyof T, string>>
function validate(form: FormState): Errors<FormState> {
const errors: Errors<FormState> = {}
if (!form.email.includes('@')) errors.email = '邮箱格式错误'
return errors
}
一句话总结:事件类型靠「组件元素推断」、受控表单用泛型 update 锁定字段与值匹配、校验结果用 Partial<Record<keyof, string» 类型化。
4. useState 与 useReducer 状态机
4.1 useState 类型化
// 显式泛型(initial 为 null 时必须)
const [user, setUser] = useState<User | null>(null)
// 推导(有初始值)
const [count, setCount] = useState(0) // number
// 函数式更新保持类型
setCount(c => c + 1)
4.2 useReducer:状态机类型化
// 判别联合 Action —— 状态机的核心
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string }
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: User }
| { type: 'FETCH_ERROR'; message: string }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START': return { status: 'loading' }
case 'FETCH_SUCCESS': return { status: 'success', data: action.data }
case 'FETCH_ERROR': return { status: 'error', message: action.message }
}
}
const [state, dispatch] = useReducer(reducer, { status: 'idle' })
// 使用:判别联合自动收窄
if (state.status === 'success') {
state.data.name // ✅ 只有 success 分支才有 data
}
4.3 为什么 useReducer 适合复杂状态
1. 所有状态迁移集中(可预测)
2. 判别联合让「每个状态只暴露该状态的数据」
3. Action 类型化 → dispatch 的写法被编译检查
4. 状态机心智模型清晰
一句话总结:useReducer + 判别联合 Action = 类型安全的状态机——每个状态只暴露对应数据,dispatch 非法 Action 编译期报错。
5. Context 与自定义 Hooks 类型
5.1 Context 的类型与默认值
type AuthContextValue = {
user: User | null
login: (email: string, pwd: string) => Promise<void>
logout: () => void
}
// 关键:默认值绝不能是「假值」——否则下游拿到的是假类型
const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx // 收窄后保证非空
}
5.2 自定义 Hooks 的返回类型
// 返回元组(类似 useState)——返回类型元组要 as const 或显式
function useToggle(initial = false) {
const [on, setOn] = useState(initial)
const toggle = useCallback(() => setOn(v => !v), [])
return [on, toggle] as const // 推断为 readonly [boolean, () => void]
}
// 使用:const [on, toggle] = useToggle() 类型正确
5.3 Hooks 类型设计原则
1. Context 用 null + 守卫 Hook(useAuth 抛错)防「假默认值」
2. Hooks 返回值尽量显式类型或 as const
3. 参数用泛型/联合保持灵活
4. 用 useCallback/useMemo 时类型自动推导
一句话总结:Context 用 null 默认值 + 守卫 Hook 保证类型非空;自定义 Hook 用 as const 或显式类型让返回结构准确。
6. 前后端共享类型与 API 契约
6.1 共享类型包(Monorepo)
把 DTO(数据传输对象)放进共享包 packages/types
后端(Nest/Fastify)与前端(React)都 import 同一份类型
→ 改一个字段,全栈编译报错,契约同步
// packages/types/src/api.ts
export type UserDTO = { id: string; name: string; email: string }
export type ApiResponse<T> = { data: T; code: number }
// 前端
import type { UserDTO } from '@repo/types'
// 后端
import type { UserDTO } from '@repo/types'
6.2 API 调用函数的类型化封装
// 封装 fetch:请求/响应都有类型
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, init)
if (!res.ok) throw new Error(`API ${res.status}`)
return res.json() as Promise<T>
}
// 具体接口
async function getUser(id: string): Promise<UserDTO> {
return api<UserDTO>(`/api/users/${id}`)
}
6.3 运行时验证补上「类型擦除」缺口
import { z } from 'zod'
// 运行时验证:类型契约 + 运行时守卫
const UserSchema = z.object({ id: z.string(), name: z.string() })
type UserDTO = z.infer<typeof UserSchema>
async function getUser(id: string): Promise<UserDTO> {
const data = await api<unknown>(`/api/users/${id}`)
return UserSchema.parse(data) // 校验不过就抛错,而非静默
}
一句话总结:前后端契约 = 共享类型包 + 泛型 api 封装 + Zod 运行时验证——编译期两边同步,运行期兜底类型擦除缺口。
7. 判别联合驱动 UI 状态
7.1 状态机 UI 模式
// 用判别联合建模「异步数据」状态
type LoadState<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string }
function Profile() {
const state = useProfileData() // 返回 LoadState<User>
switch (state.status) {
case 'loading': return <Spinner />
case 'error': return <ErrorMsg message={state.message} />
case 'success': return <UserCard user={state.data} />
}
}
7.2 状态收窄的好处
1. 每个分支只访问「该状态存在的数据」→ 编译期保证
2. switch 穷尽所有分支 → 新增状态会强制补分支(exhaustive check)
3. 消除了「data 可能是 null」的运行时判断
7.3 穷尽性检查
// 缺一个分支编译报错?用 never 兜底
function assertNever(x: never): never { throw new Error('unreachable: ' + x) }
function render(state: LoadState<User>) {
switch (state.status) {
case 'loading': return ...
case 'success': return ...
case 'error': return ...
default: return assertNever(state) // 新增状态未处理 → 编译错误
}
}
一句话总结:判别联合 + switch + assertNever 让 UI 状态机「穷尽且类型安全」——新增状态漏处理直接编译失败。
8. 常见 React 类型陷阱
陷阱一:默认值导致类型过宽
// 坏:useState(() => '') 推导 string,但其实是固定字面量
// 好:需要固定取值用 as const 或显式联合
const [tab, setTab] = useState<'overview' | 'details'>('overview')
陷阱二:事件处理器参数 any
// 坏:onChange={e => setX(e.target.value)} 若 e 是 any,一切失控
// 好:让 React 推断,或显式 ChangeEvent<HTMLInputElement>
陷阱三:Context 假默认值
// 坏:createContext<AuthContextValue>({} as AuthContextValue) —— 假值
// 好:createContext<AuthContextValue | null>(null) + 守卫 Hook
陷阱四:children 类型过宽/过窄
// 过窄:children: ReactElement 会拒绝 string
// 过宽:children: any 放弃检查
// 原则:能收窄就收窄,需要最宽就 ReactNode
陷阱五:key 用 index
// 坏:key={index} 在排序/增删时破坏状态
// 好:key 用唯一 id(配合泛型 keyOf)
陷阱六:fetch 结果直接当类型
// 坏:res.json() as UserDTO —— 运行时可能是别的
// 好:Zod 运行时验证
一句话总结:React 类型六大坑——默认值过宽、事件 any、Context 假值、children 边界、index key、fetch 裸 cast——逐一按类型化规范规避。
9. 全栈类型安全架构
┌─ 共享类型层 ─────────────────────────────┐
│ @repo/types (UserDTO/ApiResponse/...) │
│ ↓ 同一份类型 │
├─ 后端 ──────────────────────────────┐ │
│ Fastify/Nest 路由 → DTO 校验 (zod) │ │
│ → 响应类型 = 共享类型 │ │
├─ 前端 ──────────────────────────────┘ │
│ API 封装 (泛型 api<T> + zod) │
│ → LoadState 判别联合 │
│ → useReducer 状态机 │
│ → 组件 props/事件类型化 │
└────────────────────────────────────────┘
改动 UserDTO → 后端校验 + 前端组件同时编译报错
类型流向:
数据库/服务 → DTO(zod schema) → 共享类型 → API 响应
→ 前端 fetch(zod 验证) → LoadState<T> → 组件渲染
每一层都有类型/运行时验证,契约单一来源
一句话总结:全栈类型安全 = 共享类型单一来源 + 后端 zod 校验 + 前端泛型 api 封装 + LoadState 状态机——改一处契约,全栈编译与运行两层防线。
10. 速查表
| 场景 | 类型化方案 |
|---|---|
| Props | 必填/可选 + 字面量联合 |
| Children | ReactNode / 特定元素 / render props |
| 复用组件 | 泛型组件 + extends 约束 |
| 事件 | 组件推断 / 显式 KeyboardEvent |
| 表单 | keyof 泛型 update + Errors |
| 状态机 | useReducer + 判别联合 Action |
| Context | null 默认值 + 守卫 Hook |
| 前后端契约 | 共享类型包 + zod + api |
| UI 状态 | LoadState |
| fetch | zod parse 而非裸 as |
一句话记忆:React 类型安全从组件契约(Props/Children/泛型)到状态(useReducer 判别联合)到全栈契约(共享类型 + zod 运行时验证);Context 用 null+守卫防假值、fetch 用 zod 防裸 cast、状态机用 assertNever 保穷尽;判别联合让「每个状态只暴露该状态的数据」——类型系统把 React 的「非法状态」挡在编译期,运行时验证补上类型擦除缺口,全栈一份契约、两端编译联动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。