Read OSS

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

src/types/actions.ts#L19-L21

这里使用的是 type 而非 interface,这是有意为之的设计选择。根据 TypeScript issue #15300,如果 Action 是一个带有 [key: string]: unknown 索引签名的 interface,那么继承它的 interface 会意外地继承该索引签名。改用 type 可以规避这个问题。

UnknownActionAction 的基础上添加了值类型为 unknown 的索引签名,这样访问 action 上的任意属性时无需使用 any,依然是类型安全的:

src/types/actions.ts#L29-L32

已废弃的 AnyAction 与之类似,但使用 any 作为值类型——这在类型安全性上是一种退步,Redux v5 已经将其淘汰。

从 Reducer 映射推断 State 类型

最复杂的类型工作发生在 reducers.ts 中。当你调用 combineReducers({ users: usersReducer, posts: postsReducer }) 时,TypeScript 需要自动推断出合并后的 state 类型,这正是 StateFromReducersMapObject 的职责所在:

src/types/reducers.ts#L62-L68

逐步拆解这段类型定义:

  1. M[keyof M] extends Reducer<any, any, any> | undefined 是一个守卫条件——如果映射中的值不是 reducer,整个类型将解析为 never

  2. 映射类型 { [P in keyof M]: ... } 遍历 reducer 映射中的每一个键。

  3. 对于每个键,M[P] extends Reducer<infer S, any, any> ? S : never 通过条件类型推断从 reducer 类型中提取 state 类型 S

flowchart TD
    A["ReducersMapObject M = {<br/>users: Reducer&lt;User[]&gt;,<br/>posts: Reducer&lt;Post[]&gt;<br/>}"] --> B["StateFromReducersMapObject&lt;M&gt;"]
    B --> C["For key 'users': M['users'] extends Reducer&lt;infer S&gt; → S = User[]"]
    B --> D["For key 'posts': M['posts'] extends Reducer&lt;infer S&gt; → S = Post[]"]
    C --> E["Result: { users: User[], posts: Post[] }"]
    D --> E

ActionFromReducersMapObject 的原理类似,它会提取所有 reducer 的 action 类型并将其联合:

src/types/reducers.ts#L86-L96

PreloadedStateShapeFromReducersMapObject 是最复杂的变体——它推断的是每个 reducer 的输入 state 类型,这与输出类型可能不同(例如当 PreloadedStatePartial<S> 时):

src/types/reducers.ts#L103-L114

这里采用了不同的推断方式:不再使用 Reducer<infer S>,而是直接匹配函数签名 (inputState: infer InputState, action: ...) => any,从而提取第一个参数的类型。之所以这样做,是因为 Reducer 有三个泛型参数,且 PreloadedState 默认为 S,直接推断会产生歧义。

UnknownIfNonSpecific 与 NoInfer 技巧

Redux 中最巧妙的类型工具之一只有一行:

src/types/store.ts#L167

export type UnknownIfNonSpecific<T> = {} extends T ? unknown : T

这个工具类型解决了一个微妙的问题。StoreEnhancerStateExt 参数默认为 {}。当没有任何 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 使用哪个参数来推断类型参数:

src/createStore.ts#L24

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 类型系统的核心亮点:

src/types/store.ts#L220-L232

当两个 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 风格的错误压缩、开发/生产环境代码分割和产物测试的具体实现。