I am trying to extend some functionality of Redux (think similar to toolkit), and have hit a snag. From what I understand of the issue it has to do with covaraince/contravariance, but that subject is so alien to my brain that is sort of understand it, but have no clue how to get around it, or if it is even possible.
I am having issues with constraining the type of function the user is able to create as a Selector. Well.... that is not entirely true, the constraint is actually there and works (more on that later), the issue is, more precisely making the TSC pickup on the information on WHAT is the function's argument to allow for inteliSense to work as intended.
Lets look at our types:
type Selector<S> = (state: S) => any
type SelectorsConstraintMap<Selectors, S> = { [I in keyof Selectors]: Selector<S> }
type Initializer = <State extends object, Selectors extends SelectorsConstraintMap<Selectors, State>>(content: {
state: State,
selectors: Selectors
}) => void
declare const init: Initializer
init({
state: {
x: 123,
y: 456
},
selectors: {
// How to make it so that "state" here is of the type we want
foo: (state) => "foo"
}
})
Now, I know that the function actually is constrained, because if we were to change foo
to be defined as bellow, we would get an error
// Type '{ x: number; y: number; }' is not assignable to type 'number'.
foo: (state: number) => "foo"
the problem is, { x: number; y: number }
is in fact assignable to any
and if we don't define the state, state is any
so everything is okay and we do not get any intelliSense when attempting to access properties of state
.
When I change the SelectorsConstraintMap
to be a Record<string, Selector<S>
everything works fine, which leads me to believe that the problem occurs due to a MAPPED TYPE, as it probably loses some information there, or mapping somehow allows more than the record does.
Problem is, I need this to be a mapped type because I also need to "enforce" a certain naming convention, namely, selectors have to have a name that conforms to the selectCapitalCase
pattern.
TypeScript has a hard time in general when asked to infer generic type arguments and callback parameter types contextually. There's an open issue at microsoft/TypeScript#47599 asking for improvement, and in fact TypeScript has been steadily getting better at this, but there are always going to be cases where you can't get both to happen at the same time.
In this instance, it seems that having the mapped type in the constraint blocks contextual inference. I'll take you at your word that you can't just widen the constraint to
Record<string, Selector<State>>
, and that you need the mapped type to stay.Luckily it seems that you can give
selectors
a type that's the intersection of the generic type parameterSelectors
and the widerRecord<string, Selector<State>>
constraint which you know works for inference:This allows
Selectors
to still be inferred as the type of whatever is passed in asselectors
, but it also gives the methods ofselectors
the context necessary for their parameters to be inferred:That looks good. Again, note that this does not change the type inferred for
Selectors
, so any other types that depend onSelectors
should work the same as before; you don't have to try to disentangle the intersection from things, at least not in the type system.Playground link to code