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

Using createSelector with EntityAdapter's getSelectors causes '$CombinedState' error #2068

Closed
akramhussein opened this issue Feb 26, 2022 · 40 comments
Milestone

Comments

@akramhussein
Copy link

Problem

When using the EntityAdapter's exported selectors with reselects createSelector a Typescript error is thrown regarding the $CombinedState symbol not being exported.

Example:

import { createSelector } from '@reduxjs/toolkit'

import { sessionsAdapter } from '../reducers/sessions'
import { RootState } from '../types'

export const { selectAll: selectAllSessions } = sessionsAdapter.getSelectors((state: RootState) => state.sessions)
export const currentSessionSelector = createSelector(selectAllSessions, (sessions) => sessions[0])

Results in the following error

Exported variable 'currentSessionSelector' has or is using name '$CombinedState' from external module "../node_modules/redux/index" but cannot be named.ts(4023)

Package Versions

Current:

  • typescript 4.5.5
  • redux 4.1.2
  • @reduxjs/toolkit 1.7.2
  • react-redux 7.2.6

This code was previously working with the following package versions:

Previous:

  • typescript 4.4.3
  • redux 4.1.2
  • @reduxjs/toolkit 1.6.1
  • react-redux 7.2.6

I'm not sure at which point it broke as I upgraded to the latest and I'm now working backwards.

@akramhussein
Copy link
Author

akramhussein commented Feb 26, 2022

I believe this may be a reselect issue as it happens with another selector that is not using an EntityAdapter - please let me know if you want me to migrate it to there.

@markerikson
Copy link
Collaborator

Hmm. I'm not sure if it is or not. $CombinedState is a Redux core type.

@phryneas
Copy link
Member

That happens because you are building declarations.

If you don't need them, you can turn that off in your package.json. If you do need them, adding redux as a dependency will usually shut down the error.

@akramhussein
Copy link
Author

@markerikson @phryneas thanks for the quick reply.

@phryneas do you mean tsconfig.json? If so, I tried that, but unfortunately it's a monorepo whereby the redux code is in a sub-package that is being referenced. I tried setting declaration: false, however then composite does not work and if I set both to false the monorepo project won't compile. From what I read, I'll need declaration: true.

redux is already a dependency but I also tried add it to the other project referencing this one and also the root package.json, but also didn't help. Perhaps I'm doing something wrong here - will keep digging.

@akramhussein
Copy link
Author

I tested pinning reselect to 4.0.0 and it fixed this.

@adnandothussain
Copy link

Any updates on it? I tried to downgrade the peer dependency reselect from latest to 4.0.0 and the issue resolved on my end

@phryneas
Copy link
Member

phryneas commented Mar 4, 2022

This is a problem with three packages interacting with each other in a specific TypeScript setup - without a minimal reproduction I don't think we can do anything here (and even then it might be tricky)

@markerikson
Copy link
Collaborator

Yeah. What we really need is a complete project that actually reproduces the problem and shows this error happening.

Without that, there really isn't anything we can do.

@akramhussein
Copy link
Author

akramhussein commented Mar 6, 2022

Will try to create a reproducible example project. In mean time, I've identified that the breaking change occurred in 4.1.2 of reselect. Versions prior to this did not exhibit the issue.

Other versions:

  • typescript 4.6.2
  • redux 4.1.2
  • @reduxjs/toolkit 1.6.1
  • react-redux 7.2.6
  • redux-persist 6.0.0

@markerikson
Copy link
Collaborator

We definitely iterated on the Reselect TS typings between 4.1.0 and 4.1.5. 4.1.5 should be "correct" behavior. Test against that.

@akramhussein
Copy link
Author

Just tried - still shows the errors in 4.1.5. There wasn't a massive amount of code changes in reduxjs/reselect@v4.1.1...v4.1.2 so I might try and check it out and see which part triggered the break so I can narrow it down.

@mpeyper
Copy link

mpeyper commented Mar 11, 2022

I have also just run into this when reselect was updated from 4.0.0 to 4.1.5.

All selectors created with createSelector from @reduxjs/toolkit that are exported from a library are failing to build with tsc when declaration is true. Obviously we don't want to omit the declarations for a library.

@markerikson
Copy link
Collaborator

markerikson commented Mar 11, 2022

We really need a repro at this point :(

I'd also like to know if this happens with Reselect by itself as well.

(tbh I don't have much time to look at this myself right now. if anyone would like to investigate this, that would really be helpful. and if there's any chance of me actually looking at it, I need a full reproduction that I can clone and immediately see the error happening!)

@mpeyper
Copy link

mpeyper commented Mar 11, 2022

Here you go. Just run:

npm i
npm run build

While setting this up, I'm 99% sure it's the use of ReturnType<typeof rootReducer> when overriding DefaultRootState (here) that is causing the issue (for me at least). I inherited the actual project so I'm not sure if this is a normal way of handling the root state or if there is a better, more idiomatic way to do it.

@markerikson
Copy link
Collaborator

Oh, yeah, you should never actually use DefaultRootState in practice.

That type was added to the community types years ago, and tbh I don't even know why it's there. It was probably added before unknown was part of TS.

This is part of why we specifically show actually inferring the real RootState from the Redux store's type, or at least from the root reducer.

What happens if you drop that part of it, and switch over to actually following the TS setup approach shown in our docs?

@markerikson
Copy link
Collaborator

markerikson commented Mar 11, 2022

Hmm. Okay, this does seem to be RTK-related somehow.

I tried switching the example over to use RootState, and the error persists:

import { createSelector } from "@reduxjs/toolkit";
// import { DefaultRootState } from "react-redux";

import type { RootState } from './store';

type AlternateRootState = {
  key1: string,
  key2: string
}

export const selector = createSelector(
  (state: RootState) => state.key1,
  (state: RootState) => state.key1,
  (key1, key2) => key1 + key2
);

but if I switch to AlternateRootState, it goes away.

selector was inferred as:

const selector: ((state: {
    readonly [$CombinedState]?: undefined;
    key1: string;
    key2: string;
}) => string) & OutputSelectorFields<(args_0: string, args_1: string) => string & {
    clearCache: () => void;
}> & {
    clearCache: () => void;
}

The actual Redux core TS types have this definition:

/**
 * Internal "virtual" symbol used to make the `CombinedState` type unique.
 */
declare const $CombinedState: unique symbol

/**
 * State base type for reducers created with `combineReducers()`.
 *
 * This type allows the `createStore()` method to infer which levels of the
 * preloaded state can be partial.
 *
 * Because Typescript is really duck-typed, a type needs to have some
 * identifying property to differentiate it from other types with matching
 * prototypes for type checking purposes. That's why this type has the
 * `$CombinedState` symbol property. Without the property, this type would
 * match any object. The symbol doesn't really exist because it's an internal
 * (i.e. not exported), and internally we never check its value. Since it's a
 * symbol property, it's not expected to be unumerable, and the value is
 * typed as always undefined, so its never expected to have a meaningful
 * value anyway. It just makes this type distinquishable from plain `{}`.
 */
interface EmptyObject {
  readonly [$CombinedState]?: undefined
}
export type CombinedState<S> = EmptyObject & S

I'll be honest and say I don't really understand what's going on here.

@markerikson
Copy link
Collaborator

markerikson commented Mar 11, 2022

Mmm... actually, if this is related to preloadedState... didn't we use to declare that it was DeepPartial, and then removed that because it actually isn't (ie, you can only provide top-level fields, and have to provide the full nested data for any of those)?

If so, does this $CombinedState thing even make sense any more?

but I also have no idea why the latest Reselect types are causing problems with this and declarations either.

@mpeyper
Copy link

mpeyper commented Mar 11, 2022

The root of the problem seems to be combineReducers as the issue goes away if I construct the reducer myself. The latest reselect types work well when given hand rolled types or other inferred types.

That said, the upgrade of reselect did introduce a bunch of implicit any errors where before it seemed to infer the types of selectors from previous ones in the chain, but I actually think typing each one properly is somehow more correct.

@markerikson
Copy link
Collaborator

Yeah, the Reselect 4.1 types require that you be very explicit and exact about the input selector types, so it can correctly infer the types of the final selector arguments.

Sounds like this ultimately comes down to a Redux core types issue in combination with Reselect.

We'll have to dig back into this, but it's possible that this type may not even be needed any more.

@Methuselah96 , I know you've done some poking around the core types. Any knowledge of $CombinedState's background and if it's still needed?

@phryneas
Copy link
Member

From what I understand, CombinedState is a phantom type marker that means "this comes from a combineReducer" to be able to use that information in preloadedState.
So, essentially to distinguish between

createStore(reducerFunction<State>) // this can either have preloadedState of undefined or State, but not Partial<State> 
createStore(combineReducers({ a, b }) // `undefined`, `{a}`, `{b}` or `{a,b}`, but not a partial `a` or `b`
createStore(combineReducers({ a: combineReducers({ c, d }), b}) // `undefined`, `{a}`, `{b}` or `{a,b}`, and also partial `a`, but not partial `b`

@phryneas
Copy link
Member

Now that I think about it, that should probably be just stripped from store.getState's result and that would solve a lot of problems.

@mpeyper
Copy link

mpeyper commented Mar 16, 2022

I'm back from a few days holiday and diving into this again.

I'm a bit unclear why combineReducers gets special treatment compared to other reducer functions when it comes to preloadedState. Don't all reducer functions, including combineReducers, follow the same rules when it comes to initialising state?

Why should create migrating from:

const reducer1 = (state: string = "value1", action: AnyAction) => { ... };
const reducer2 = (state: string = "value2", action: AnyAction) => { ... };
const reducer3 = (state: string = "value3", action: AnyAction) => { ... };

export const rootReducer = combineReducers({
  key1: reducer1,
  key2: combineReducers({
    key1: reducer2,
    key2: reducer3,
  }),
});

to:

const initialState = {
  key1: "value1",
  key2: {
    key1: "value2",
    key2: "value3",
  },
};

export const rootReducer = (state = initialState, action: AnyAction) => { ... };

change how configureStore is allowed to be used?

Obviously this comes from an extremely naive perspective of not understanding how or why we got here, so just tell me it must be this way and I'll drop it.

@markerikson
Copy link
Collaborator

That's the thing. I'm not actually convinced this is necessary.

Going back in the Redux repo, the $CombinedState type was added in reduxjs/redux#3485 , to fix reduxjs/redux#2808 . This specifically mentions a DeepPartial type that was used with createStore.

The thing is, though: a Redux store's initial state cannot be "deep partial". It's only partial at the top level.

Tbh I'm very confused on whether $CombinedState is needed at all at this point.

@mpeyper
Copy link

mpeyper commented Mar 16, 2022

For anyone reading this before a real solution is presented, I've taken the CleanState type from here:

import type { CombinedState } from '@reduxjs/toolkit';

type CleanState<T> = T extends CombinedState<infer S> ? { [K in keyof S]: CleanState<S[K]> } : T;

and dropped it in my project so I can wrap the RootState in it:

export type RootState = CleanState<ReturnType<typeof rootReducer>>;

With this, the project builds fine and the definitions for emitted types for the selectors and state are as expected.

@mpeyper
Copy link

mpeyper commented Mar 16, 2022

Actually, the above workaround does remove issue with $CombinedState, but the new reselect types are still giving me all kinds of headaches for other types from external modules that cannot be named (to be honest, I don't really know what this error means or what it is about these types that is causing it).

I've discovered that I can work around the issue if I type the selector manually (bypassing the inferred types), but that kind of defeats the purpose of having the complicated types in reselect IMO.

I'm going to raise a new ticket in reselect repo for this as I feel like the new types there are the actual root of the issue rather than here, although I do think the whole $CombinedState should still be looked into.

@Methuselah96
Copy link
Member

Methuselah96 commented Mar 16, 2022

@markerikson The reason it's necessary is because the allowed preloaded state is different depending on whether you're using combineReducers or not.

A normal reducer's type is:

type Reducer = <S, A>(state: S | undefined, action: A) => S;

combineReducer actually creates a reducer with a slightly more permissive initial state:

type Reducer = <S, A>(state: Partial<S> | undefined, action: A) => S;

The introduction of $CombinedState was an attempt to fix this issue by saying "Hey, if I have the $CombinedState property then I'm allowed to be Partial, otherwise I can't be Partial."

The real solution would be to add a third generic to the reducer type which defines the initial state:

type Reducer = <S extends InitialState, A, InitialState>(state: InitialState, action: A) => S;

Then we could get rid of all of these $CombinedState hacks. However this third generic would spread to a lot of places and not look very pretty. Let me know if you want me to make a PR to show you what it would look like.

@Methuselah96
Copy link
Member

Methuselah96 commented Mar 16, 2022

reduxjs/redux#4314 is a very rough draft of what it could look like.

@mpeyper
Copy link

mpeyper commented Mar 16, 2022

combineReducer actually creates a reducer with a slightly more permissive initial state

The real solution would be to add a third generic to the reducer type which defines the initial state

This is sort of what I was hinting at with my comments above about this not being specifically a combineReducers feature, but rather any reducer function can be more permissive about what state it would accept vs what it will produce, so the types should either allow that for any reducer, or disallow it for combineReducers to be consistent across the board.

@markerikson
Copy link
Collaborator

Taking a step back for a bit:

is this maybe actually "just" an issue with Reselect itself? It sounds like we've established that this started happening with the Reselect 4.1.x type changes.

Does the same sort of error happen if you do something with a RootState type and some other usage that is combined with declarations: true?

@mpeyper
Copy link

mpeyper commented Mar 16, 2022

No, I agree. The new types in reselect 4.1.x seem to be the problem here. Using RootState directly is not an issue elsewhere.

@mpeyper
Copy link

mpeyper commented Mar 16, 2022

I’ll just add that the new reselect types are also not an issue unless you emit the declarations for the project.

@phryneas
Copy link
Member

I'm just going to take a stab in the dark and say that the CombinedReducer type there could also have some implications with the TS problems we are seeing with configureStore recently. Not sure if this is reselect only.

@mpeyper
Copy link

mpeyper commented Mar 17, 2022

I haven't got around to opening a issue in the reselect repo yet, but anecdotally, the types appear to create the issue when reselect infers the types to be things like symbols and enums, where they can by used as types and runtime values (I think that's what makes them special). I haven't dug deeper to see if there are other types that trigger it or not, just noticed that all my errors were either $CombinedState (symbol) or enums from external modules.

@markerikson
Copy link
Collaborator

Which could definitely be a clue, because those are runtime values and not just compile-time types

@steinso
Copy link

steinso commented May 13, 2022

Has there been any progress on this issue, or is there a workaround in the mean time?

@MarksPoint
Copy link

I'm intested in this, too! We're currently using the workaround where we use npm-force-resolutions to use dependency reselect version 4.1.0 instead of 4.1.5.

@markerikson
Copy link
Collaborator

Nope. Last couple months have been very busy, and I haven't had time to look at issues like this.

@thibaultboursier
Copy link

We've encountered the same $CombinedState issue.

Here is what we did to fix that:

1. Create reducers map object

const reducers = {
  foo: fooSlice.reducer,
  bar: barSlice.reducer,
  // Register all your reducers as usual.
};

2. Define a correctly typed root reducer

type ReducersMapObject = typeof reducers;

export const rootReducer: Reducer<
  StateFromReducersMapObject<ReducersMapObject>,
  ActionFromReducersMapObject<ReducersMapObject>
> = combineReducers(reducers);

Unlike combineReducers return type from Redux, we don't wrap ReducersMapObject with CombineState for the state part.

3. Create and export root state as usual

export type RootState = ReturnType<typeof rootReducer>;

Finally, we'll have a state that does not include $CombinedState anywhere.

CleanState generic type solution is elegant but brings some issues:

  • It uses recursion, which transforms original state passed in parameter to a big object type literal.
  • Then, when emitting types, generated files have an enormous size and we encounter heap out of memory issues.

@maxizipitria
Copy link

maxizipitria commented Jul 23, 2022

I've encountered the same issue and solved it with @thibaultboursier solution but with a small change

const reducers = {
  foo: fooSlice.reducer,
  bar: barSlice.reducer,
};

// No need to explicitly set the type here
export const rootReducer = combineReducers(reducers);

export type RootState = StateFromReducersMapObject<typeof reducers>

And in case you need to get the dispatch type, here is what I've used:

type AppActions = ActionFromReducersMapObject<typeof reducers>;
type AppDispatch = ExtractDispatchExtensions<[ThunkMiddlewareFor<RootState>]> & Dispatch<AppActions>;

@markerikson
Copy link
Collaborator

This should be fixed as of https://github.com/reduxjs/redux-toolkit/releases/tag/v2.0.0-alpha.5 , since we've dropped $CombinedState from the redux core.

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

No branches or pull requests

10 participants