Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TypeScript defintion of StoreEnhancer does not allow state extension with replaceReducer #3482

Closed
mhelmer opened this issue Jul 24, 2019 · 30 comments · Fixed by #3772
Closed

Comments

@mhelmer
Copy link

mhelmer commented Jul 24, 2019

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

@mhelmer mhelmer changed the title TypeScript defintion StoreEnhancer does not allow state extension with replaceReducer TypeScript defintion of StoreEnhancer does not allow state extension with replaceReducer Jul 24, 2019
@cellog
Copy link
Contributor

cellog commented Aug 16, 2019

This actually prevents moving the replaceReducer test to typescript (#3500)

@mhelmer
Copy link
Author

mhelmer commented Aug 17, 2019

I'm not sure about that fix since it essentially removes the type safety from replaceReducer, if I'm not mistaken.

I'm thinking it could be solved by adding an extra type generic to the Store type

export interface Store<S = any, A extends Action = AnyAction, ReplacedState = S> {
  /*... */
  replaceReducer(nextReducer: Reducer<ReplacedState, A>): void
  /*... */
} 

The StoreEnhancer type would be updated to

export type StoreEnhancer<Ext = {}, StateExt = {}> = (
  next: StoreEnhancerStoreCreator
) => StoreEnhancerStoreCreator<Ext, StateExt>
export type StoreEnhancerStoreCreator<Ext = {}, StateExt = {}> = <
  S = any,
  A extends Action = AnyAction
>(
  reducer: Reducer<S, A>,
  preloadedState?: PreloadedState<S>
) => Store<S & StateExt, A, S> & Ext

This maintains type safety and makes it possible to call replaceReducer with the original reducer from the outside. It would be breaking change though, since the returned store has a different signature and requires that you implement replaceReducer if you add extra state in an enhancer.

I could open a PR if you think it's a reasonable solution.

@cellog
Copy link
Contributor

cellog commented Aug 17, 2019

@mhelmer are you referring to the original issue or to #3507? #3507 included a different solution, which modifies replaceReducer to return the store, but with a different type signature, so type safety is not only preserved, it is enforced. But if you were referring to #3507, I am not sure I understood how it removes type safety, would like to hear more detail if you're willing!

@mhelmer
Copy link
Author

mhelmer commented Aug 17, 2019

Yes, #3507. It adds a type generic to replaceReducer. With that I believe you can call it with any reducer and the generics are inferred. I'm not sure, perhaps that's desireable.

@cellog
Copy link
Contributor

cellog commented Aug 17, 2019

Ok, I'll see if I can break it, thanks for the notice

@cellog
Copy link
Contributor

cellog commented Aug 17, 2019

@markerikson and @timdorr is there any history behind the typings for applyMiddleware that would be good to know about?

The reason I ask is because it's exceedingly difficult to apply the existing types, and they allow at least 1 invalid case (calling applyMiddleware with no middlewares).

In my experimentation, we can actually extract the dispatch and state extensions as well as action extensions from the middlewares and combine them pretty easily, so that no types need be passed, they will all be inferred.

However, this will break BC, so would need to be released in a breaking version.

So, before proceeding further, I'd love to see what people think is a good path. Here's what I propose:

  1. We use a bunch of any in applyMiddleware.ts to make the existing function overload types work
  2. In a separate PR, I put up the new types for review and discuss how to handle this.

It will require us to reach out to external middlewares maintainers to update their types as well. As part of the PR for (2), I will try updating the types for thunk and saga and see what we need

@markerikson
Copy link
Contributor

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.

@cellog
Copy link
Contributor

cellog commented Aug 20, 2019

@mhelmer #3521 should assuage your concerns - the returned reducer strongly types its state. The inference is intentional - it causes the types of the reducer to attach themselves to the new store, as it should.

@timdorr
Copy link
Member

timdorr commented Aug 20, 2019

Looks like this issue is resolved in the TS rewrite, so I'll close this out. Stay tuned for release...

@timdorr timdorr closed this as completed Aug 20, 2019
@mhelmer
Copy link
Author

mhelmer commented Aug 21, 2019

@timdorr:

Actually, the original issue is not resolved at all.

The problem as described is for store enhancers that add extra state. In such cases it would make more sense if replaceReducer is called with the original reducer as input.

Post #3507 we can call replaceReducer with any reducer and it's supposed to return a store with the same type of reducer. This is not the case when an enhancer is adding extra state. Should also be noted that it's a breaking change. I don't think this can be solved without a breaking change to the types though.

Also, my concerns for the typings were about the reducer input, which can now be any reducer.

@timdorr timdorr reopened this Aug 21, 2019
@cellog
Copy link
Contributor

cellog commented Aug 24, 2019

I see the issue. The problem I fixed was that replaceReducer did not update the base state. We need to be able to pass a store enhancer extension to replaceReducer to fix this. I'll see if there is a way to magically extract the existing extension from the store

@cellog
Copy link
Contributor

cellog commented Aug 24, 2019

@mhelmer Now that I'm on the same page as you, I opened a pull request that may fix the issue for you. Do you think you could check out the branch I based #3524 on to see if it fixes your issue?

@mhelmer
Copy link
Author

mhelmer commented Aug 27, 2019

@cellog: I tried against the branch, but I could not get it to work. I'm not sure if I'm missing something here..

import {
  StoreEnhancer,
  Action,
  AnyAction,
  Reducer,
  createStore,
  PreloadedState
} 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?: PreloadedState<S>
  ) => {
    const wrappedReducer: Reducer<S & ExtraState, A> = null as any;
    const wrappedPreloadedState: PreloadedState<S & ExtraState> = null as any;
    const store = createStore(wrappedReducer, wrappedPreloadedState);
    return {
      ...store,
      replaceReducer: (nextReducer: Reducer<S, A>) => {
        const nextWrappedReducer: Reducer<S & ExtraState, A> = null as any;
        return store.replaceReducer(nextWrappedReducer);
      }
    };
  };

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

I get the following error:

src/redux/store-enhancers/ext-enhancer.ts:25:66 - error TS2322: Type '<S, A extends Action<any> = AnyAction>(reducer: Reducer<S, A>, preloadedState?: PreloadedState<S>) => { replaceReducer: (nextReducer: Reducer<S, A>) => Store<S & ExtraState, A, ExtraState, {}>; dispatch: Dispatch<...>; getState(): S & ExtraState; subscribe(listener: () => void): Unsubscribe; [Symbol.observable](): O...' is not assignable to type 'StoreEnhancerStoreCreator<{}, ExtraState>'.
  Type '{ replaceReducer: (nextReducer: Reducer<S, A>) => Store<S & ExtraState, A, ExtraState, {}>; dispatch: Dispatch<A>; getState(): S & ExtraState; subscribe(listener: () => void): Unsubscribe; [Symbol.observable](): Observable<...>; }' is not assignable to type 'Store<S & ExtraState, A, ExtraState, {}>'.
    Types of property 'replaceReducer' are incompatible.
      Type '(nextReducer: Reducer<S, A>) => Store<S & ExtraState, A, ExtraState, {}>' is not assignable to type '<NewState = S & ExtraState, NewActions extends A = A>(nextReducer: Reducer<NewState, NewActions>) => Store<NewState & ExtraState, NewActions, ExtraState, {}>'.
        Types of parameters 'nextReducer' and 'nextReducer' are incompatible.
          Types of parameters 'state' and 'state' are incompatible.
            Type 'S' is not assignable to type 'NewState'.
              'S' is assignable to the constraint of type 'NewState', but 'NewState' could be instantiated with a different subtype of constraint '{}'.

 25   const enhancer: StoreEnhancer<{}, ExtraState> = createStore => <
                                                                     ~
 26     S,
    ~~~~~~
...
 30     preloadedState?: PreloadedState<S>
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 31   ) => {
    ~~~~~~~~

  ../redux/index.d.ts:436:54
    436 export type StoreEnhancer<Ext = {}, StateExt = {}> = (
                                                             ~
    437   next: StoreEnhancerStoreCreator<Ext, StateExt>
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    438 ) => StoreEnhancerStoreCreator<Ext, StateExt>
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    The expected type comes from the return type of this signature.


Found 1 error.

@cellog
Copy link
Contributor

cellog commented Aug 27, 2019

Thanks for trying it out, I'll see if I can figure out why it fails

@cellog
Copy link
Contributor

cellog commented Aug 27, 2019

OK, so here's the deal.

The reason this fails is that the generic for state used in the definition of replaceReducer could theoretically be called with any random restriction of state, and it could contradict the type passed in.

This is fixable in your example by adding as Store<S & ExtraState, A, ExtraState, {}> after the return:

      return {
        ...store,
        replaceReducer: (nextReducer: Reducer<S, A>) => {
          const nextWrappedReducer: Reducer<S & ExtraState, A> = null as any
          return store.replaceReducer(nextWrappedReducer)
        }
      } as Store<S & ExtraState, A, ExtraState, {}>

I am uncertain if there is a way in typescript to get inference from a generic and not allow passing in arbitrary types. If there is, we would need to use that here. Otherwise, this will need to be updated in documentation as well. What do you think?

@cellog
Copy link
Contributor

cellog commented Aug 28, 2019

@mhelmer I spent several hours on this, and asked advice of a typescript expert, and basically this is an OK way to solve this particular issue. I don't think there is a simpler way unless we completely rewrite the types for Store, and even then I can't find any other way to do this. Open to suggestions.

@mhelmer
Copy link
Author

mhelmer commented Aug 28, 2019

Apologies for my slow reply. If store.replaceReducer needs to accept an arbitrary reducer then yeah, I think this is an ok way to solve this. The types became a bit hard to grasp, but I guess that's how it has to be. Nice work and thanks for the help :)

@timdorr
Copy link
Member

timdorr commented Aug 29, 2019

I'm planning on doing a major for this TS rewrite, so a Store rewrite is totally on the table, FWIW.

@cellog
Copy link
Contributor

cellog commented Aug 29, 2019

I think there is hope yet. This code:

interface Store {
  a<S>(t: S): S[]
}

interface EnhancedStore<Extension> extends Store {
  a<S>(t: S & Extension): (S & Extension)[]
}

const f: Store = {
  a: (t) => {
    return [t]
  }
}

function enhance<Extension>(s: Store, t: Extension): EnhancedStore<Extension> {
  return {
    a: (a) => {
      return [{
        ...a,
        ...t
      }]
    }
  }
}

works without error. I'm going to gradually make it more complex until it hits the sweet spot

@mhelmer
Copy link
Author

mhelmer commented Aug 29, 2019

Just for some context on my use case here with a concrete example. I'm using redux-persist and I thought the setup clutters the configureStore a bit too much, so I wanted to stick it into an enhancer, allowing me to compose it with other enhancers (applyMiddleware etc).

The setup wraps the root reducer and adds some extra state (ExtraState). It also creates a persistor object that I wanted to attach to the store (Ext). Finally, I also wanted to set up hot reloading in configureStore without having to know about the extra state, Ie:

function configureStore(preloadedState) {
  // ...
  if (process.env.NODE_ENV !== 'production' && module.hot) {
    module.hot.accept('src/reducers', () => { store.replaceReducer(rootReducer) });
  }
}

So, my enhancer looks like this:

function createPersistEnhancer(persistConfig: PersistConfig) {
  return (createStore) => (reducer, preloadedState, enhancer) => {
    const persistedReducer = persistReducer(persistConfig, reducer);
    const store = createStore(persistedReducer, preloadedState, enhancer);
    const persistor = persistStore(store);

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

@cellog
Copy link
Contributor

cellog commented Aug 29, 2019

Further update: I tried removing some of the weird typing hacks in the tests like:

    const wrappedReducer: Reducer<S & ExtraState, A> = null as any
    const wrappedPreloadedState: PreloadedState<S & ExtraState> = null as any

and replacing them with real code:

      const wrappedReducer = (state: S & ExtraState | undefined, action: any) => {
        return {
          ...(state ? state : {}),
          extraField: 'extra'
        }
      }
      const wrappedPreloadedState = {
        ...preloadedState,
        extraField: 'extra'
      }

to get:

function mhelmersonExample() {
  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?: PreloadedState<S>
    ) => {
      const wrappedReducer = (state: S & ExtraState | undefined, action: any) => {
        return {
          ...(state ? state : {}),
          extraField: 'extra'
        }
      }
      const wrappedPreloadedState = {
        ...preloadedState,
        extraField: 'extra'
      }
      const store = createStore<S & ExtraState>(wrappedReducer, wrappedPreloadedState)
      return {
        ...store,
        replaceReducer: <NewState extends S, NewActions extends A>(
          nextReducer: Reducer<NewState, NewActions>
        ) => {
          const nextWrappedReducer = (state: S | undefined, action: any) => {
            return {
              ...(state ? state : {}),
              extraField: 'extra'
            }
          }
          return store.replaceReducer(nextWrappedReducer)
        }
      }
    }

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

Now, and this is very interesting, the type failure is at createStore - the definition for Reducer is not flexible enough here. I'll keep digging

Argument of type '(state: (S & ExtraState) | undefined, action: any) => { extraField: string; }' is not assignable to parameter of type 'Reducer<S & ExtraState, AnyAction>'.
  Type '{ extraField: string; }' is not assignable to type 'S & ExtraState'.
    Type '{ extraField: string; }' is not assignable to type 'S'.
      '{ extraField: string; }' is assignable to the constraint of type 'S', but 'S' could be instantiated with a different subtype of constraint '{}'.ts(2345)

Note that the ExtraState portion is incorrectly being compared to S

@cellog
Copy link
Contributor

cellog commented Aug 29, 2019

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

First of all, the fix.

8ca8290#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:

8ca8290#diff-09c07e35fbab9793e5f037dc3c72c878L128

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

7e7395f

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

@mhelmer
Copy link
Author

mhelmer commented Aug 30, 2019

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> {
  // ...
}

@cellog
Copy link
Contributor

cellog commented Aug 30, 2019

just pushed a commit that adds this detail, and a few more granular tests to ensure typing is propagated to the store.

@mhelmer
Copy link
Author

mhelmer commented Aug 30, 2019

Alright, so it seems to work now. Need to do unsafe cast of preloadedState but that's it.

@Izhaki
Copy link

Izhaki commented Jan 7, 2022

I have a similar issue which I believe is related.

I'm trying to create an enhancer which adds a reducer, but I cannot for the life of me figure out how to type it.

The types below (CodeSandbox) are clearly incorrect, but are the ones that yield least errors.

Soon as I StoreEnhancer<{}, StateExt> and StoreEnhancerStoreCreator<{}, StateExt> as @cellog suggested I get more, rather than less, errors.

import {
  combineReducers,
  StoreEnhancer,
  Dispatch,
  PreloadedState,
  StoreEnhancerStoreCreator,
  AnyAction,
  Reducer
} from "redux";

const extraReducer = combineReducers({ dummy: () => null });
// type StateExt = ReturnType<typeof extraReducer>;

export default function tools(): StoreEnhancer {
  return (createStore: StoreEnhancerStoreCreator) => <S, A extends AnyAction>(
    reducer: Reducer<S, A>,
    preloadedState?: PreloadedState<S>
  ) => {
    const rootReducer: Reducer<S, A> = (state, action) => {
      const intermediateState = reducer(state, action);
      const finalState = extraReducer(intermediateState, action);
      return finalState;
    };

    const store = createStore(rootReducer, preloadedState);

    const dispatch: Dispatch<A> = (action) => {
      console.log(action);
      return store.dispatch(action);
    };

    return { ...store, dispatch };
  };
}

Context: We wish to provide features to users that they can mix-and-match; each feature is a combination of a reducer and a middleware (and in one case also add to the store API), so we wish to ship these as enhancers.

@markerikson
Copy link
Contributor

No current answers here, but this is something that would be nice to resolve as part of a Redux 5.0.

@Izhaki fwiw, have you considered shipping that feature in some other form? For example, something like https://github.com/microsoft/redux-dynamic-modules or https://github.com/fostyfost/redux-eggs could let you define the correlated reducer+middleware as more of its own combined/encapsulated piece.

@Methuselah96
Copy link
Member

It's been a while since I've looked at this, but I believe #3772 resolves this issue.

@Methuselah96
Copy link
Member

There's an example in the tests for that PR that shows how the state could be extended.

@Izhaki
Copy link

Izhaki commented Jan 7, 2022

Thanks guys!

redux-eggs looks awesome, but does not seem to support store API extensions (and in our case it's an additional dependency of a library to a library).

I've added my comment not because I can't get it to function, but because I can't seem to be able to type it.

I'll be looking forward for the fix to be merged and then verify it with my setup.

This issue was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants