Is it possible to constrain function arguments to a predefined type through a mapped type?

42 views Asked by At

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.

1

There are 1 answers

0
jcalz On BEST ANSWER

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 parameter Selectors and the wider Record<string, Selector<State>> constraint which you know works for inference:

type Initializer =
  <State extends object, Selectors extends SelectorsConstraintMap<Selectors, State>>(
    content: {
      state: State,
      selectors: Selectors & Record<string, Selector<State>>
    }
  ) => void

This allows Selectors to still be inferred as the type of whatever is passed in as selectors, but it also gives the methods of selectors the context necessary for their parameters to be inferred:

init({
  state: {
    x: 123,
    y: 456
  },
  selectors: {
    foo: state => state.x // okay
    //   ^? (parameter) state: { x: number; y: number; }
  }
})

That looks good. Again, note that this does not change the type inferred for Selectors, so any other types that depend on Selectors 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