引言
前端应用越复杂,状态层越容易成为「类型的地狱」:一个 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. 状态层的类型目标
- 2. 状态建模:判别联合
- 3. Zustand 的 create 类型推导
- 4. Zustand selector 与切片
- 5. Redux Toolkit 的 createSlice
- 6. Immer 与不可变更新的类型
- 7. 异步状态建模
- 8. 跨模块共享类型契约
- 9. 大型应用的状态边界
- 10. 速查表与一句话记忆
- 延伸阅读
1. 状态层的类型目标
状态层的类型安全不是「不报错」,而是实现四个目标:
- 无手写类型断言:reducer/action/selector 的类型全部从定义推导;
- 无
any泄漏:API 响应、localStorage、路由参数等「外部边界」显式校验后再入 state; - 状态迁移可穷举:
loading → success | error用判别联合表达,不可能出现非法组合; - 跨模块契约清晰:共享状态的类型只在「一处定义」,消费方推导而非复制。
// 反例: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 的两层选择:
| 方式 | 代码 | 用途 |
|---|---|---|
| 单值 selector | s => s.user.name | 常见,重渲染少 |
| 多值 selector | s => [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 专题 — 服务端状态与数据层
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。