TypeScript 状态管理类型安全:Zustand、Redux Toolkit 与 Immer

系统覆盖前端状态管理在 TypeScript 下的类型安全实践:单一数据源与状态分层建模、Zustand 的 create 类型推导与 selector 优化、Redux Toolkit 的 createSlice 自动类型生成、Immer 不可变更新的类型安全、异步状态(loading/error/data)的判别联合建模、跨模块共享类型契约与派生选择器,以及大型应用的状态边界设计,帮助开发者写出「类型即文档、重构不破胆」的状态层。

引言

前端应用越复杂,状态层越容易成为「类型的地狱」:一个 user 是 User | null | { loading: true } | { error: ... }?一个 action 的 payload 在 reducer 里被 any 放行?在 TypeScript 下,好的状态管理库应该做到类型从定义自动推导、绝不写手写类型断言。Zustand 与 Redux Toolkit 是两条主路线,它们分别用「单一 store 的 create 推导」与「slice 的自动类型生成」把类型安全内建进 API。本文将讲透它们背后的类型机制,以及异步状态、选择器与跨模块边界的建模方法。

前置:/typescript-react-fullstack-typesafe/(React 类型安全)、/typescript-advanced-types/(映射/条件类型)、/typescript-runtime-validation-typesafe/(运行时校验)。

目录

1. 状态层的类型目标

状态层的类型安全不是「不报错」,而是实现四个目标:

  1. 无手写类型断言:reducer/action/selector 的类型全部从定义推导;
  2. 无 any 泄漏:API 响应、localStorage、路由参数等「外部边界」显式校验后再入 state;
  3. 状态迁移可穷举:loading → success | error 用判别联合表达,不可能出现非法组合;
  4. 跨模块契约清晰:共享状态的类型只在「一处定义」,消费方推导而非复制。
// 反例:any 泄漏(几乎所有状态 bug 的温床)
const [state, setState] = useState<any>({});
// 正例:显式边界校验
const [user, setUser] = useState<User | null>(null);

关键心智:类型系统只能保证「你已经声明的关系」,不能替你发明关系。所以边界校验(/typescript-runtime-validation-typesafe/)是状态类型的先决条件。

2. 状态建模:判别联合

复杂状态的正确建模工具是判别联合(discriminated union)——用可区分的 type/status 字段让 TypeScript 自动收窄:

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading"; progress?: number }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: AsyncState<User>) {
  switch (state.status) {
    case "idle": return "待加载";
    case "loading": return `加载中…${state.progress ?? ""}`;
    case "success": return state.data.name;      // ← 只有这里能访问 data
    case "error": return state.error.message;    // ← 只有这里能访问 error
  }
}

好处:

  • 穷举性:switch 覆盖所有分支,漏掉的分支会在严格模式报错;
  • 非法组合不可表示:{ status: "success", error } 根本写不出来;
  • 错误处理内建:data 与 error 在类型层面互斥。

工程纪律:凡是「要么没数据、要么在加载、要么失败、要么成功」的状态,一律用这个模式——它是状态层的「第一原则」。

3. Zustand 的 create 类型推导

Zustand 的核心是把「store 定义」与「store 类型」绑定在一次调用里:

import { create } from "zustand";

interface CounterState {
  count: number;
  inc: () => void;
  dec: () => void;
}

const useStore = create<CounterState>()((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
  dec: () => set((s) => ({ count: s.count - 1 })),
}));

关键类型机制:

  • create<CounterState>()(...) 的 () 是「curried」签名:让 TS 正确推断 set 的类型;
  • set 的更新函数:(state) => Partial<State>,可安全只更新部分字段;
  • useStore((s) => s.count):selector 参数自动带类型;
  • useStore.getState() / setState():store 外访问同样全类型。
// 类型安全的 selector:只订阅需要的片段,避免整 store 重渲染
const count = useStore((s) => s.count);
const selectCount = (s: CounterState) => s.count;
const count = useStore(selectCount); // 可复用的 selector

4. Zustand selector 与切片

Zustand 的类型安全在**切片(slices)**模式下的威力在于「组合但不破类型」:

interface AppState {
  user: UserSlice;
  cart: CartSlice;
}
// 把切片类型组合成总 state,create 时拆开定义
const useStore = create<AppState>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a),
}));

selector 的两层选择:

方式代码用途
单值 selectors => s.user.name常见,重渲染少
多值 selectors => [s.user, s.cart]需 useShallow 做浅比较
import { useShallow } from "zustand/react/shallow";
const [user, cart] = useStore(useShallow((s) => [s.user, s.cart]));

工程要点:selector 返回新对象/数组时默认「引用比较」会导致无限渲染——useShallow 做浅比较化解。类型上,Zustand 会推导 selector 返回值,把「该订阅什么」也变成可检查的契约。

5. Redux Toolkit 的 createSlice

Redux Toolkit 用 createSlice 把 reducer、actions、类型一次性生成:

import { createSlice, type PayloadAction } from "@reduxjs/toolkit";

interface CounterState { value: number }
const initialState: CounterState = { value: 0 };

const counterSlice = createSlice({
  name: "counter",
  initialState,
  reducers: {
    increment: (state) => { state.value += 1; },           // Immer 直接改
    addAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;                        // payload 类型自动绑定
    },
  },
});

export const { increment, addAmount } = counterSlice.actions;
export default counterSlice.reducer;

类型魔法:

  • PayloadAction<T>:把 action 的 payload 类型绑定到 reducer 参数,调用方也自动获得;
  • reducer 返回值推导:ActionCreator 的类型由 reducers 对象推导,不用手写 union;
  • store.getState() / store.dispatch():从 store 定义推导(ReturnType<typeof store.getState>)。
// 全类型 dispatch:错字/错误 payload 在编译期被拦
dispatch(counterSlice.actions.addAmount(2));   // ✅
dispatch(counterSlice.actions.addAmount("2")); // ❌ Type '"2"' is not assignable

6. Immer 与不可变更新的类型

Redux Toolkit 内建 Immer,让你「直接改 state」却得到不可变更新。类型层面有两个注意点:

// Immer 里的「读时类型」与「写时类型」
const slice = createSlice({
  initialState: { list: [{ id: 1, name: "a" }] },
  reducers: {
    rename: (state, action: PayloadAction<{ id: number; name: string }>) => {
      const item = state.list.find((x) => x.id === action.payload.id);
      if (item) item.name = action.payload.name;  // ✅ 写时用 draft 类型
      // const item2 = state.list.filter(...)[0]; // 拿到的是 readonly 引用
    },
  },
});

关键类型规则:

  • draft 可写:state 参数是「草稿」类型(Immer 的 Draft<T>),允许直接赋值;
  • 返回 readonly 的方法:filter/map 等返回 readonly 视图,改写前要先「取出可变引用」(find 返回可写的元素引用);
  • createAsyncThunk:异步 action 的 pending/fulfilled/rejected 三个状态自动生成,配合 AsyncState 判别联合建模最省心。
const fetchUser = createAsyncThunk("user/fetch", async (id: string) => {
  const res = await api.get<User>(`/users/${id}`);
  return res.data; // 返回类型自动成为 fulfilled 的 payload 类型
});

7. 异步状态建模

异步是状态层的「最高复杂度」来源。类型安全的关键是把异步状态显式建模(呼应第 2 节),并让 reducer 只处理「状态迁移」而不丢失类型:

interface UserState {
  user: AsyncState<User>;
}

reducers: {
  // 用 extraReducers 响应 thunk 的三个阶段
  [fetchUser.pending.type]: (state) => {
    state.user = { status: "loading" };
  },
  [fetchUser.fulfilled.type]: (state, action: PayloadAction<User>) => {
    state.user = { status: "success", data: action.payload };
  },
  [fetchUser.rejected.type]: (state, action) => {
    state.user = { status: "error", error: new Error(action.error.message) };
  },
}

工程要点:

  • 并发处理:多请求竞态时,用请求 id 或 AbortController 区分「哪次响应算数」,类型上用「请求序列号」建模;
  • 缓存与失效:{ status: "success", data, updatedAt } 里加时间戳,类型表达「可能过期」;
  • 永远不让 UI 猜状态:组件 switch(state.status),穷举分支(第 2 节模式)——类型就是「状态机说明书」。

8. 跨模块共享类型契约

大型应用里,多个模块共享同一份状态,容易「各写各的类型、悄悄漂移」。类型安全的做法是单一权威来源:

// 1. 类型只在「状态定义处」声明一次
export type UserState = AsyncState<User>;
export type { User, Session } from "./domain"; // 领域模型单独文件

// 2. selector 导出一个「类型化查询」库
export const selectUser = (s: RootState): UserState => s.user;
export const selectUserName = (s: RootState): string | null => {
  const u = s.user;
  return u.status === "success" ? u.data.name : null;
};

// 3. 组件只 import 类型 + selector,不 import reducer 内部结构

反例:组件里写 state.user.data.name 的「路径感知」——一旦状态结构调整,全站爆红。正例:把「如何取、取什么、可能是什么」封装成 selector,类型跟随定义走,重构成本降到单点。

9. 大型应用的状态边界

状态类型化到极致,还需要「边界纪律」防止类型被 any/断言腐蚀:

边界原则
API 响应用 /typescript-api-type-generation/ 生成 + 运行时校验后入 state
localStorage读写都走「Schema 校验 + 类型断言」的封装
路由参数useParams 的返回值先校验成 Record<"id", string>
时间/日期统一用 number(timestamp)或 Date,禁止字符串混用
第三方库边界处显式映射成自己的类型,不透传 unknown
// localStorage 的类型安全封装
const KEY = "cart";
export function loadCart(): Cart {
  const raw = localStorage.getItem(KEY);
  if (raw === null) return [];
  return parseCart(raw); // 运行时校验,失败回退默认值
}

核心目标:让「外部世界」(网络、存储、路由)在进入 store 前就被净化成可信类型,store 内部从此「不再有意外」。

10. 速查表与一句话记忆

问题一句话答案
状态怎么建模判别联合:idle/loading/success/error
Zustand 类型从哪来create<T>()(...) 一次调用推导所有类型
Redux 类型从哪来createSlice 自动生成 actions/reducer 类型
异步状态怎么处理createAsyncThunk + 判别联合 + extraReducers
选择器怎么类型安全selector 返回类型自动推导,useShallow 防无限渲染
跨模块共享类型只定义一次,selector 封装读取路径
外部数据怎么入 store运行时校验净化后再赋值,杜绝 any

一句话记忆:状态类型 = 判别联合建模(穷举不漂移)+ 库的推导(Zustand create / Redux createSlice)+ 边界净化(外部数据先校验)+ selector 封装(读取路径单点)。

延伸阅读

  • /typescript-react-fullstack-typesafe/ — React 组件与状态的联动类型
  • /typescript-runtime-validation-typesafe/ — 外部边界的运行时校验
  • /typescript-api-type-generation/ — API 类型生成与契约
  • /typescript-async-concurrency-control/ — 异步并发与竞态
  • /typescript-design-patterns-practice/ — 状态模式的类型安全实现
  • 前端专题 — React/Vue 状态管理生态
  • Node.js 专题 — 服务端状态与数据层

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. TypeScript 应用安全加固:依赖、注入与敏感信息防护
  2. TypeScript Monorepo 工程化:pnpm、Turborepo 与多包协作
  3. Node.js Worker Threads:TypeScript 并行计算实战