React 全栈类型安全:组件类型、事件类型与前后端契约

系统覆盖 React 全栈开发中的类型安全实践:Props 与 Children 类型化、泛型组件、事件与表单类型、useState/useReducer 类型化、Context 与 Hooks 类型安全、React 与 API 的类型契约(请求/响应模型)、前后端共享类型、判别联合驱动 UI 状态机、以及常见 React 类型陷阱。

引言

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 类型化

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必填/可选 + 字面量联合
ChildrenReactNode / 特定元素 / render props
复用组件泛型组件 + extends 约束
事件组件推断 / 显式 KeyboardEvent
表单keyof 泛型 update + Errors
状态机useReducer + 判别联合 Action
Contextnull 默认值 + 守卫 Hook
前后端契约共享类型包 + zod + api
UI 状态LoadState 判别联合 + assertNever
fetchzod parse 而非裸 as

一句话记忆:React 类型安全从组件契约(Props/Children/泛型)到状态(useReducer 判别联合)到全栈契约(共享类型 + zod 运行时验证);Context 用 null+守卫防假值、fetch 用 zod 防裸 cast、状态机用 assertNever 保穷尽;判别联合让「每个状态只暴露该状态的数据」——类型系统把 React 的「非法状态」挡在编译期,运行时验证补上类型擦除缺口,全栈一份契约、两端编译联动。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. TypeScript 错误处理:Result 模式、类型化错误与错误边界实战
  2. TypeScript 测试策略:单元测试、类型测试与测试替身实战
  3. TypeScript 构建性能优化:增量编译、缓存与工具链选型