Read OSS

Redux's Type System: Conditional Types, Generic Inference, and Type-Level Testing

Advanced

Prerequisites

  • Articles 1-4
  • Intermediate TypeScript: generics, conditional types, infer keyword
  • Familiarity with TypeScript utility types

Redux's Type System: Conditional Types, Generic Inference, and Type-Level Testing

Redux's runtime code is famously small. Its type system, however, is anything but. The TypeScript definitions span four files and employ advanced techniques — conditional types with infer, mapped types that derive state shapes from reducer maps, a clever UnknownIfNonSpecific guard, and composable generics on StoreEnhancer that accumulate type extensions through intersection. These types have been battle-tested across millions of TypeScript projects.

In this article, we'll trace the full type hierarchy, examine the inference machinery, and look at how Redux validates its types at the CI level using .test-d.ts files.

The Type Hierarchy: Action to StoreEnhancer

Redux's types form a dependency chain across four files. Each file builds on the previous:

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

The foundation is Action:

src/types/actions.ts#L19-L21

This is a type, not an interface — a deliberate choice. TypeScript issue #15300 means interfaces extending Action would unexpectedly inherit an index signature if Action were an interface with [key: string]: unknown. Using type avoids this.

UnknownAction extends Action with an index signature using unknown values, making it safe to access arbitrary properties on actions without any:

src/types/actions.ts#L29-L32

The deprecated AnyAction is identical but uses any — a strict downgrade in type safety that Redux v5 moved away from.

State Inference from Reducer Maps

The most sophisticated type work happens in reducers.ts. When you call combineReducers({ users: usersReducer, posts: postsReducer }), TypeScript needs to infer the combined state type automatically. This is the job of StateFromReducersMapObject:

src/types/reducers.ts#L62-L68

Let's unpack this:

  1. The conditional M[keyof M] extends Reducer<any, any, any> | undefined is a guard — if the values in the map aren't reducers, the type resolves to never.

  2. The mapped type { [P in keyof M]: ... } iterates over every key in the reducer map.

  3. For each key, M[P] extends Reducer<infer S, any, any> ? S : never uses conditional type inference to extract the state type S from the reducer type.

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 works similarly, extracting and unioning the action types from all reducers:

src/types/reducers.ts#L86-L96

PreloadedStateShapeFromReducersMapObject is the most complex variant — it infers the input state type for each reducer (which may differ from the output type when PreloadedState is Partial<S>):

src/types/reducers.ts#L103-L114

This one uses a different inference approach: instead of Reducer<infer S>, it matches against the function signature (inputState: infer InputState, action: ...) => any to extract the first parameter type. This is necessary because the Reducer type has three generic parameters and PreloadedState defaults to S, making it ambiguous which parameter to infer.

UnknownIfNonSpecific and NoInfer Tricks

One of Redux's most clever type utilities is a single line:

src/types/store.ts#L167

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

This solves a subtle problem. StoreEnhancer has a StateExt parameter that defaults to {}. When no enhancer provides state extensions, StateExt resolves to {}. But {} extends T is only true when T is {} or a wider type — not when T is a specific type like { count: number }.

So UnknownIfNonSpecific<{}> gives unknown, while UnknownIfNonSpecific<{ count: number }> gives { count: number }. This matters because Store<S, A, StateExt> uses getState(): S & StateExt. If StateExt were {}, then S & {} simplifies to S — fine. But unknown is the identity for intersections (S & unknown = S), which is semantically cleaner and avoids edge cases where {} might cause unexpected structural subtyping.

The NoInfer trick in createStore.ts controls which argument TypeScript uses to infer type parameters:

src/createStore.ts#L24

type NoInfer<T> = [T][T extends any ? 0 : never]

This wraps T in a tuple and immediately indexes back into it. TypeScript sees the conditional type and defers inference, preventing that position from being used to infer the generic. In createStore's return type, NoInfer<Ext> ensures the Ext parameter is inferred solely from the enhancer, not from how the return value is used.

Tip: TypeScript 5.4+ added a built-in NoInfer<T> utility type. Redux's manual version exists for backward compatibility with older TypeScript versions.

StoreEnhancer Composability

The StoreEnhancer type is the crown jewel of Redux's type system:

src/types/store.ts#L220-L232

When two enhancers are composed, their types accumulate:

Enhancer Ext StateExt
applyMiddleware(thunk) { dispatch: ThunkDispatch } {}
devTools() { __DEVTOOLS__: true } {}
compose(applyMiddleware(thunk), devTools()) { dispatch: ThunkDispatch } & { __DEVTOOLS__: true } {}

The Middleware type has an interesting quirk — its first generic parameter is named _DispatchExt with a leading underscore:

src/types/middleware.ts#L22-L30

The comment is revealing: "TODO: see if this can be used in type definition somehow (can't be removed, as is used to get final dispatch type)". The _DispatchExt parameter doesn't appear in the function signature — it exists solely so applyMiddleware can extract it and thread it into StoreEnhancer<{ dispatch: Ext }>.

This is why applyMiddleware needs overloads for 1-5 middleware parameters:

src/applyMiddleware.ts#L24-L52

Each overload extracts the Ext from each Middleware<Ext, S, any> and intersects them into StoreEnhancer<{ dispatch: Ext1 & Ext2 & ... }>. TypeScript can't do this with variadic generics alone (you'd need variadic intersection, which doesn't exist), so individual overloads are the only option.

Type-Level Testing with .test-d.ts

Redux validates its TypeScript inference using Vitest's expectTypeOf in .test-d.ts files. These tests never execute — they're checked at type-check time only:

test/typescript/store.test-d.ts#L60-L76

Key patterns in these type tests:

  • expectTypeOf(x).toEqualTypeOf<T>(): Asserts exact type equality
  • expectTypeOf(x).toMatchTypeOf<T>(): Asserts structural subtyping
  • // @ts-expect-error: Asserts that the next line should produce a type error

For example, line 78 verifies that creating a store with an incomplete preloaded state is a type error:

// @ts-expect-error
createStore(reducer, { b: { c: 'c' }, e: brandedString })

If this line ever stopped erroring (due to a type regression), the test would fail because @ts-expect-error on a non-erroring line is itself an error.

The test file at test/typescript/store.test-d.ts covers store creation, dispatch, getState, subscribe, replaceReducer, and observable — essentially validating every generic on the Store interface.

Tip: If you maintain a library with complex generics, .test-d.ts files are invaluable. They catch type regressions that runtime tests can never detect — like a generic that silently widens to any or an overload that stops matching the right branch.

What's Next

We've traced the full arc of Redux's type system — from the simple Action<T> foundation through conditional inference on reducer maps, the UnknownIfNonSpecific guard, composable StoreEnhancer generics, and type-level testing. In the final article, we'll leave the source code and enter the build pipeline: how Redux transforms these 17 files into optimized, multi-format bundles with React-inspired error mangling, dev/prod code splitting, and artifact testing.