You've run into a design limitation of TypeScript. See microsoft/TypeScript#38872 for more information.
The problem is that you gave selector
and render
property callbacks whose parameters (data
and test
) had no explicit type annotation. Thus they are contextually typed; the compiler needs to infer types for these parameters and cannot use them directly to infer other types. The compiler holds off on this and tries to infer D
and S
from what it currently knows. It can infer D
as {test: boolean}
because the data
property is of this type. But it has no idea what to infer S
as, and it defaults to unknown
. At this point the compiler can begin to perform contextual typing: the data
parameter of the selector
callback is now known to be of type {test: boolean}
, but the test
parameter of the render
callback is given the type unknown
. At this point, type inference ends, and you're stuck.
According to a comment by the lead architect of TypeScript:
In order to support this particular scenario we'd need additional inference phases, i.e. one per contextually sensitive property value, similar to what we do for multiple contextually sensitive parameters. Not clear that we want to venture there, and it would still be sensitive to the order in which object literal members are written. Ultimately there are limits to what we can do without full unification in type inference.
So what can be done? The problem is the interplay between contextual callback parameter types and generic parameter inference. If you're willing to give one of those up, you can cut the Gordian Knot of type inference. For example, you can give up some contextual callback parameter typing by manually annotating some callback parameter types:
Component({
data: { test: true },
selector: (data: { test: boolean }) => data.test, // annotate here
render: (test) => test, // test is boolean
})
Or, you can give up on generic parameter type inference by manually specifying your generic type parameters:
Component<{ test: boolean }, boolean>({ // specify here
data: { test: true },
selector: (data) => data.test,
render: (test) => test, // test is boolean
})
Or, if you're not willing to do that, maybe you can create your Props<D, S>
values in stages where each stage only requires a little bit of type inference. For example, you can replace property values with function parameters (see above quote "similar to what we do for multiple contextually sensitive parameters"):
const makeProps = <D, S>(
data: D, selector: (data: D) => S, render: (data: S) => any
): Props<D, S> => ({ data, selector, render });
Component(makeProps(
{ test: true },
(data) => data.test,
(test) => test // boolean
));
Or, more verbosely but possibly more understandable, use afluent builder pattern:
const PropsBuilder = {
data: <D,>(data: D) => ({
selector: <S,>(selector: (data: D) => S) => ({
render: (render: (data: S) => any): Props<D, S> => ({
data, selector, render
})
})
})
}
Component(PropsBuilder
.data({ test: true })
.selector((data) => data.test)
.render((test) => test) // boolean
);
I tend to prefer builder patterns in cases where TypeScript's type inference capabilities fall short, but it's up to you.
Playground link to code