Type constraint lost when invoking function with the same return type, is there a way to enforce it?

73 Views Asked by At

TLDR:

I am having issues with forcing TSC to constrain the return type of my curried function call, that is as short and abstract as I can define it.

Issues with type information not being properly constrained can be seen on this TSPlayground link, if you just want to look at types and get a brief information. I've left in comments in that code (its not too long, but should assist in understanding). Otherwise, lets dive deep:

Section 1: Context (skip to the following section if you are familiar with Redux and Reselect)

We have a Selector:

type Selector<S = any, R = unknown> = (state: S) => R

which is a simple input output function, state comes in and something comes out. Conceptually there could be computation inside of a selector, which means that you'd want to cache the results and only re-compute on state change, however, due to Redux's philosophy of immutability, the state ALWYAS changes, so there is no caching, which is perfectly fine if you are only working with simple selectors such as:

state => state.foo

for more complex cases we have a function createSelector which has a signature as follows

//pseudocode
function createSelector<T extends Selectors[]>(...dependencies: T, resultFn: (...args: ReturnType<T>) => R) => R

now obviously this type cannot work in typescript but I think it conveys the idea, the function is suppose to take in other selectors, and a "resultFn" which takes the result of the selectors and produces a computed result. All the "hard computation" should be inside the resultFn, which should be cached based on the result of the selectors, this way, simple selectors will re-run (due to immutability) but the computation heavy (resultFn) function will not. This is the principle upon which the reselect library is based.

Section 2: Improving on Reselect

Since I am making a Redux extension (sort of) I have all my selectors defined in a controlled environment, which means that I can reference them using only strings, or rather, each named selector is defined:

interface DEFINED_SELECTORS {
    selectFoo: number
    selectBar: string
}

type SELECTOR_NAMES = keyof DEFINED_SELECTORS

type SelectorResult<T> = T extends SELECTOR_NAMES ? DEFINED_SELECTORS[T] : never

This allows us to accept not only Selectors as dependencies for our cacheable selector function, but also strings (or rather SELCTOR_NAMES). As such we define our dependency as follows:

type Dependency<S = any> = SELECTOR_NAMES | Selector<S>

The original createSelector function, as defined in Reselect has a signature that goes as follows:

function createSelector([dep1 [, dep2[, dep3[, depN ...]]]], resultFn)

with the N number of optional dependencies preceding a required result function parameter.

To my knowledge, this kind of type is not definable in typescript, thus I have tried using something along the lines of:

function createSelector([dep1 [, dep2 [, dep3 [, depN ...]]]]) (resultFn)

It may be worth noting that the resultFn in both cases accepts results of invoking dependency functions as its arguments, meaning that the number of dependencies passed to createSelector corresponds to the number of arguments the resultFn will recieve, with their types being the results of invoking those dependencies. The return type of the resultFn then becomes the return type of a selector created by the function. And with that, we have finally reached our desired type for the createSelector function, as follows:

function createSelector<S, T extends Array<Dependency<S>>>(
  ...args: T
): <R>(
  callback: (
    ...args: {
      [I in keyof T]: T[I] extends SELECTOR_NAMES
        ? SelectorResult<T[I]>
        : ReturnType<Exclude<T[I], SELECTOR_NAMES>>
    }
  ) => R
) => Selector<S, R>

Section 3: Using the createSelector function

Now, when this function is invoked, the result is a Selector<S,R> where the type of the R type-argument is whichever type was returned by the resultFn, so if we were to do something like:

interface DEFINED_SELECTORS {
    selectFoo: number
    selectBar: string
}

type ExampleState = {
    foo: number
    bar: string
}

// mouseover: Selector<unknown, ExampleState>
const selectState = createSelector("selectFoo", "selectBar")((foo, bar): ExampleState => ({ foo, bar }))

Logically the R is now ExampleState however the type of a type-argument S is unknown, which makes sense, and this is where this little thing comes in:

type SelectorInitializer = (cfg: {
    state: ExampleState
    selectors: Record<string, Selector<ExampleState>>
}) => void // type is void just for example, realistically it wouldn't be void

declare const init: SelectorInitializer

So now we can use:

init({
    state: {
        foo: 123,
        bar: 'stringy'
    },
    selectors: {
        selectFoo: state => state.foo, // this is a selector 
        selectBar: state => state.bar, // this is also a selector
        notASelector: 123, // this is not a selector, so it causes errors

        // which then clearly means this is a selector, as it causes no errors
        // works, however, if we mouseover the property we notice that it this Selector's
        // type argument `S` remains unknown, whilst `selectFoo` and `selectBar` were constrained
        selectFizz: createSelector('selectBar', "selectFoo")((bar, foo) => "fizz"),

        // which is not an issue when using `string` dependencies, like shown above
        // however when we do decide to use Selector type (function) dependencies.... 
        // it obviously causes an error, as the state is "unknown"
        selectBuzz: createSelector("selectFoo", state => state.bar, state => 123)((foo, bar, num) => num),
    }
})

So for some reason, the constraint put in place within the SelectorInitializer doesn't apply to these selectors created with createSelector function, even though it infact does constrain the object properties to be a Selector type....

What makes matters worse is, if we change our createSelector function's type a bit by removing the resultFn part (for the sake of experimenting), and instead immidiatly return a Selector, the type-argument S gets properly constrained, however now we are missing the R as obviously, we did not define it anywhere...

declare function createSelector<S, R, T extends Dependency<S>[]>(
    ...args: T
): Selector<S, R>

init({
    state: {
        bar: '123',
        foo: 123
    },
    selectors: {
        selectBuzz: createSelector("selectFoo", state => state.bar, state => 123)
    }
})

Section 4: The ISSUE

So I am confused, as TSC clearly wants to, and has no issue, constraining the Selector type when it is returned by the createSelector function.... however, when it is returned by a a curried function, it suddenly is no longer able to do so.

What am I missing here? Why are we able to constrain a Selector type returned by createSelector(...) whilst simultaneously not one returned by createSelector(...)(...) and more importantly, how to fix this?

I am also open to suggestions involving changing the function signatures, maybe the curried style I went for (due to not being able to have mixed ...arg types where N optional Dependency arguments precede a required resultFN argument) is wrong and you have a better idea?

Take a look at all the types on the TSPlayground link.

Lastly, I've tried to reduce this issue to a minimum reproducible example, and this is what I came up with... the actual createSelector function is not suppose to return a selector rather a DeferredSelector (you can see the type in the TSPlayground), so the constraint put in place by the SelectorInitializer should be expanded to accept a Record of Selector | DeferredSelector but that opens a whole other can of worms, which I am hoping I will work once this inference works, though it might be worth keeping in mind.

Hope the wall of text is not too much to read, I feel like I could remove Section 1, if the community thinks that the context is not necessary for the issue at hand.

0

There are 0 best solutions below