Redux 的类型系统:条件类型、泛型推断与类型层面测试
前置知识
- ›第 1–4 篇文章
- ›TypeScript 中级水平:泛型、条件类型、infer 关键字
- ›熟悉 TypeScript 内置工具类型
Redux 的类型系统:条件类型、泛型推断与类型层面测试
Redux 的运行时代码以简洁著称,但其类型系统却截然相反。TypeScript 类型定义分布在四个文件中,综合运用了多种高级技巧——带 infer 的条件类型、从 reducer 映射中派生状态结构的映射类型、巧妙的 UnknownIfNonSpecific 守卫,以及通过交叉类型在 StoreEnhancer 上累积类型扩展的可组合泛型。这套类型系统已在数百万个 TypeScript 项目中经历了实战检验。
本文将完整梳理类型层次结构,剖析推断机制,并介绍 Redux 如何借助 .test-d.ts 文件在 CI 阶段对类型进行验证。
类型层次结构:从 Action 到 StoreEnhancer
Redux 的类型跨四个文件形成了一条依赖链,每个文件都在前一个的基础上构建:
flowchart TD
subgraph "actions.ts"
Act["Action<T extends string>"]
UA["UnknownAction"]
AC["ActionCreator<A, P>"]
end
subgraph "reducers.ts"
Red["Reducer<S, A, PreloadedState>"]
RMO["ReducersMapObject<S, A, PreloadedState>"]
SFRMO["StateFromReducersMapObject<M>"]
end
subgraph "store.ts"
Disp["Dispatch<A>"]
Store["Store<S, A, StateExt>"]
SE["StoreEnhancer<Ext, StateExt>"]
UINS["UnknownIfNonSpecific<T>"]
end
subgraph "middleware.ts"
MW["Middleware<_DispatchExt, S, D>"]
MWAPI["MiddlewareAPI<D, S>"]
end
Act --> UA
Act --> Red
Red --> RMO
RMO --> SFRMO
Act --> Disp
Red --> Store
Disp --> Store
Store --> SE
Disp --> MWAPI
MWAPI --> MW
整个体系的基础是 Action:
这里使用的是 type 而非 interface,这是有意为之的设计选择。根据 TypeScript issue #15300,如果 Action 是一个带有 [key: string]: unknown 索引签名的 interface,那么继承它的 interface 会意外地继承该索引签名。改用 type 可以规避这个问题。
UnknownAction 在 Action 的基础上添加了值类型为 unknown 的索引签名,这样访问 action 上的任意属性时无需使用 any,依然是类型安全的:
已废弃的 AnyAction 与之类似,但使用 any 作为值类型——这在类型安全性上是一种退步,Redux v5 已经将其淘汰。
从 Reducer 映射推断 State 类型
最复杂的类型工作发生在 reducers.ts 中。当你调用 combineReducers({ users: usersReducer, posts: postsReducer }) 时,TypeScript 需要自动推断出合并后的 state 类型,这正是 StateFromReducersMapObject 的职责所在:
逐步拆解这段类型定义:
-
M[keyof M] extends Reducer<any, any, any> | undefined是一个守卫条件——如果映射中的值不是 reducer,整个类型将解析为never。 -
映射类型
{ [P in keyof M]: ... }遍历 reducer 映射中的每一个键。 -
对于每个键,
M[P] extends Reducer<infer S, any, any> ? S : never通过条件类型推断从 reducer 类型中提取 state 类型S。
flowchart TD
A["ReducersMapObject M = {<br/>users: Reducer<User[]>,<br/>posts: Reducer<Post[]><br/>}"] --> B["StateFromReducersMapObject<M>"]
B --> C["For key 'users': M['users'] extends Reducer<infer S> → S = User[]"]
B --> D["For key 'posts': M['posts'] extends Reducer<infer S> → S = Post[]"]
C --> E["Result: { users: User[], posts: Post[] }"]
D --> E
ActionFromReducersMapObject 的原理类似,它会提取所有 reducer 的 action 类型并将其联合:
PreloadedStateShapeFromReducersMapObject 是最复杂的变体——它推断的是每个 reducer 的输入 state 类型,这与输出类型可能不同(例如当 PreloadedState 为 Partial<S> 时):
src/types/reducers.ts#L103-L114
这里采用了不同的推断方式:不再使用 Reducer<infer S>,而是直接匹配函数签名 (inputState: infer InputState, action: ...) => any,从而提取第一个参数的类型。之所以这样做,是因为 Reducer 有三个泛型参数,且 PreloadedState 默认为 S,直接推断会产生歧义。
UnknownIfNonSpecific 与 NoInfer 技巧
Redux 中最巧妙的类型工具之一只有一行:
export type UnknownIfNonSpecific<T> = {} extends T ? unknown : T
这个工具类型解决了一个微妙的问题。StoreEnhancer 的 StateExt 参数默认为 {}。当没有任何 enhancer 提供 state 扩展时,StateExt 就会解析为 {}。而 {} extends T 只在 T 为 {} 或更宽泛的类型时才成立——对于像 { count: number } 这样具体的类型则不成立。
因此,UnknownIfNonSpecific<{}> 得到 unknown,而 UnknownIfNonSpecific<{ count: number }> 得到 { count: number }。这一点之所以重要,是因为 Store<S, A, StateExt> 中的 getState() 返回类型为 S & StateExt。如果 StateExt 是 {},那么 S & {} 可以化简为 S,看起来没问题。但使用 unknown 在语义上更加清晰——S & unknown 等于 S,同时避免了 {} 在结构子类型判断中可能引发的边界情况。
createStore.ts 中的 NoInfer 技巧则用于控制 TypeScript 使用哪个参数来推断类型参数:
type NoInfer<T> = [T][T extends any ? 0 : never]
这段代码先将 T 包裹进一个元组,再立即通过索引访问取回。TypeScript 看到条件类型后会推迟推断,使该位置不会参与泛型推断。在 createStore 的返回类型中,NoInfer<Ext> 确保 Ext 参数仅由 enhancer 来决定,而不受返回值使用方式的影响。
提示: TypeScript 5.4+ 已内置
NoInfer<T>工具类型。Redux 中手动实现的版本是为了兼容较早版本的 TypeScript。
StoreEnhancer 的可组合性
StoreEnhancer 类型是 Redux 类型系统的核心亮点:
当两个 enhancer 组合时,它们的类型会不断累积:
| Enhancer | Ext | StateExt |
|---|---|---|
applyMiddleware(thunk) |
{ dispatch: ThunkDispatch } |
{} |
devTools() |
{ __DEVTOOLS__: true } |
{} |
compose(applyMiddleware(thunk), devTools()) |
{ dispatch: ThunkDispatch } & { __DEVTOOLS__: true } |
{} |
Middleware 类型有一个有趣的细节——它的第一个泛型参数被命名为 _DispatchExt,以下划线开头:
src/types/middleware.ts#L22-L30
注释中写道:"TODO: see if this can be used in type definition somehow (can't be removed, as is used to get final dispatch type)"。_DispatchExt 并未出现在函数签名中——它存在的唯一目的,是让 applyMiddleware 能够提取它并将其传入 StoreEnhancer<{ dispatch: Ext }>。
这也是 applyMiddleware 需要为 1 到 5 个 middleware 参数分别提供重载的原因:
src/applyMiddleware.ts#L24-L52
每个重载从各自的 Middleware<Ext, S, any> 中提取 Ext,并将它们交叉合并为 StoreEnhancer<{ dispatch: Ext1 & Ext2 & ... }>。由于 TypeScript 目前无法对可变参数做交叉操作,只能通过逐一列举重载来实现。
使用 .test-d.ts 进行类型层面测试
Redux 使用 Vitest 的 expectTypeOf 在 .test-d.ts 文件中验证 TypeScript 的推断行为。这些测试从不实际执行——它们只在类型检查阶段生效:
test/typescript/store.test-d.ts#L60-L76
类型测试中常见的几种模式:
expectTypeOf(x).toEqualTypeOf<T>():断言类型完全相等expectTypeOf(x).toMatchTypeOf<T>():断言结构子类型关系// @ts-expect-error:断言下一行应当产生类型错误
例如,第 78 行验证了使用不完整的 preloaded state 创建 store 会触发类型错误:
// @ts-expect-error
createStore(reducer, { b: { c: 'c' }, e: brandedString })
如果这行代码因类型退化而不再报错,测试本身就会失败——因为 @ts-expect-error 标注在一个不报错的行上,本身就是一个错误。
test/typescript/store.test-d.ts 涵盖了 store 创建、dispatch、getState、subscribe、replaceReducer 和 observable,几乎验证了 Store interface 上的每一个泛型。
提示: 如果你在维护一个拥有复杂泛型的库,
.test-d.ts文件是不可或缺的工具。它能捕获运行时测试永远无法发现的类型退化问题——比如某个泛型悄悄地被拓宽为any,或者某个重载不再匹配正确的分支。
下一步
至此,我们已经完整梳理了 Redux 类型系统的全貌——从简单的 Action<T> 基础,到 reducer 映射上的条件推断、UnknownIfNonSpecific 守卫、可组合的 StoreEnhancer 泛型,再到类型层面的测试实践。最后一篇文章将离开源码,转向构建系统:Redux 如何将这 17 个文件转换为经过优化、支持多种格式的产物,以及 React 风格的错误压缩、开发/生产环境代码分割和产物测试的具体实现。