redux: TypeScript defintion of StoreEnhancer does not allow state extension with replaceReducer

Do you want to request a feature or report a bug?

The StoreEnhancer TypeScript interface does not seem to have the appropriate type for the replaceReducer property of the enhanced store. I would consider it a bug in the type definition.

What is the current behavior?

When implementing the StoreEnhancer interface with ExtraState, the type signature of replaceReducer on the “enhanced” store is coupled to the type of the wrapped reducer.

Given a StoreEnhancer of type

StoreEnhancer<Ext, ExtraState>

the returned store is of type

Store<S & StateExt, A> & Ext

with the replaceReducer property as such

(nextReducer: Reducer<S & ExtraState, A>) => void

Returning a store with a replaceReducer that accepts the original reducer gives the following type-error:

Type '<S, A extends Action<any> = AnyAction>(reducer: Reducer<S, A>, preloadedState?: DeepPartial<S> | undefined) => { replaceReducer: (nextReducer: Reducer<S, A>) => void; dispatch: Dispatch<A>; getState(): S & ExtraState; subscribe(listener: () => void): Unsubscribe; [Symbol.observable](): Observable<...>; }' is not assignable to type 'StoreEnhancerStoreCreator<{}, ExtraState>'.
      Type '{ replaceReducer: (nextReducer: Reducer<S, A>) => void; dispatch: Dispatch<A>; getState(): S & ExtraState; subscribe(listener: () => void): Unsubscribe; [Symbol.observable](): Observable<S & ExtraState>; }' is not assignable to type 'Store<S & ExtraState, A>'.
        Types of property 'replaceReducer' are incompatible.
          Type '(nextReducer: Reducer<S, A>) => void' is not assignable to type '(nextReducer: Reducer<S & ExtraState, A>) => void'.
            Types of parameters 'nextReducer' and 'nextReducer' are incompatible.
              Types of parameters 'state' and 'state' are incompatible.
                Type 'S | undefined' is not assignable to type '(S & ExtraState) | undefined'.
                  Type 'S' is not assignable to type '(S & ExtraState) | undefined'.
                    Type 'S' is not assignable to type 'S & ExtraState'.
                      Type 'S' is not assignable to type 'ExtraState'.

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem via https://jsfiddle.net or similar.

The type check fails for the following code:

import {
  StoreEnhancer,
  Action,
  AnyAction,
  Reducer,
  createStore,
  DeepPartial
} from "redux";

interface State {
  someField: "string";
}

interface ExtraState {
  extraField: "extra";
}

const reducer: Reducer<State> = null as any;

function stateExtensionExpectedToWork() {
  interface ExtraState {
    extraField: "extra";
  }

  const enhancer: StoreEnhancer<{}, ExtraState> = createStore => <
    S,
    A extends Action = AnyAction
  >(
    reducer: Reducer<S, A>,
    preloadedState?: DeepPartial<S>
  ) => {
    const wrappedReducer: Reducer<S & ExtraState, A> = null as any;
    const wrappedPreloadedState: S & ExtraState = null as any;
    const store = createStore(wrappedReducer, wrappedPreloadedState);
    return {
      ...store,
      replaceReducer: (nextReducer: Reducer<S, A>): void => {
        const nextWrappedReducer: Reducer<S & ExtraState, A> = null as any;
        store.replaceReducer(nextWrappedReducer);
      }
    };
  };

  const store = createStore(reducer, enhancer);
  store.replaceReducer(reducer);
}

See src/index.ts in the linked codesandbox example that implements the same function that would be expected to type-check, followed by another function of how it actually has to be done.

https://codesandbox.io/s/redux-store-enhancer-types-s6d3v

The example is based on the typescript test for the enhancer at test/typescript/enhancers.ts in this repo. The code doesn’t execute (due to unsafe casts of null as any), but it is the type check that is of interest here.

What is the expected behavior?

When a store is created with a store enhancer that wraps a reducer and adds state, I would expect that replaceReducer on the returned store can be called with the original rootReducer.

const store = createStore(rootReducer, preloadedState, enhancer)
// ...
store.replaceReducer(rootReducer)

It would be the responsibility of the enhancer to appropriately replace the wrapped reducer. I.e return a store such as:

{
  ...store,
  replaceReducer: (nextReducer: Reducer<S, A>) => {
    store.replaceReducer(wrapReducer(nextReducer))
  },
}

Which versions of Redux, and which browser and OS are affected by this issue? Did this work in previous versions of Redux?

Redux version: 4.0.4, OS: Ubuntu 19.04 Browser: N/A Did this work in previous versions of Redux?: Not that I know of

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Comments: 30 (20 by maintainers)

Commits related to this issue

Most upvoted comments

Nice. I will try it out this evening.

Just one thing about that final example. The persistReducer function doesn’t add any extra state so it doesn’t really capture the issue. The type signature would rather be something like

function persistReducer<S, A>(reducer: Reducer<S, A>): Reducer<S & ExtraState, A> {
  // ...
}

OK!! I am satisfied I have found a solution that actually works.

First of all, the fix.

https://github.com/reduxjs/redux/pull/3524/commits/8ca829033dde4d4c45672865347840fe6985683c#diff-b52768974e6bc0faccb7d4b75b162c99L345

It turns out that by providing default values, type inference was short-circuiting, and causing the error.

Next, the tests were all using contrived non-realistic examples, so I fixed the tests, first here:

https://github.com/reduxjs/redux/pull/3524/commits/8ca829033dde4d4c45672865347840fe6985683c#diff-09c07e35fbab9793e5f037dc3c72c878L128

and then in a follow-up commit for the rest of the tests:

https://github.com/reduxjs/redux/pull/3524/commits/7e7395f733265d689ef91b56a8e208fd854c2aa3

As you can see, the final example of your code, now working, is:


function finalHelmersonExample() {
  function persistReducer<S>(config: any, reducer: S): S {
    return reducer
  }

  function persistStore<S>(store: S) {
    return store
  }

  function createPersistEnhancer(persistConfig: any): StoreEnhancer {
    return createStore => <S, A extends Action = AnyAction>(
      reducer: Reducer<S, A>,
      preloadedState?: any
    ) => {
      const persistedReducer = persistReducer(persistConfig, reducer)
      const store = createStore(persistedReducer, preloadedState)
      const persistor = persistStore(store)

      return {
        ...store,
        replaceReducer: nextReducer => {
          return store.replaceReducer(
            persistReducer(persistConfig, nextReducer)
          )
        },
        persistor
      }
    }
  }
}

Hopefully this is the right solution! Let me know if it works for you as well as it does here @mhelmer

I have always stayed away from any typings discussions, so I have no idea what’s changed when.

FWIW, while passing 0 middlewares to applyMiddleware makes no sense semantically, it is legal syntactically. applyMiddleware passes them to compose, and compose handles the 0-arg case by returning arg => arg. So, while it’s not meaningful, you can do it as far as I can see.

I’d prefer to not have any real breaking types changes at the moment if at all possible. On the other hand, if we’re going to make changes, now would be the time to do it.