Issue
I'm having a little trouble understanding how Typescript infers props in JSX. The issues has come up in the following context -- I'm trying to make a component that accepts another component as a prop, e.g. (a simplified example):
function WrapperComponent<T extends FooComponentType>(
{FooComponent}:{FooComponent:T}
){
return FooComponent({className:"my-added-class"})
}
Where FooComponentType
is the type of a component that has a className
prop:
type FooComponentType = (props:{className:string}) => JSX.Element
What I'd like, is for Typescript to disallow any attempt to pass in a component that doesn't have a className
prop. Now, I recognize the typing above will not achieve that because React components are functions and so the props argument is covariant, i.e., a type that would be satisfied by a component with a className
prop will also be satisfied by a component without one. Therefore, both of these components legitimately extend FooComponentType
const ComponentNoClass = ()=><div/>
const ComponentWClass = (props:{className:string})=><div/>
A workaround that I'm familiar with is to use a conditional time to essentially force the type to be bivariant by having some other type resolve to never
if the passed component doesn't exactly match (i.e., if both types don't extend each other). My thought here was I could use such a type to make the return type of WrappedComponent
be never
if the passed component didn't have the className
prop. So, to that end, I wrote the following helper type:
type JsxIfHasClassNameProps<
T extends FooComponentType
> = FooComponentType extends T ? JSX.Element : never
And used it to create a "smarter" version of WrappedComponent
:
function CheckedWrapperComponent<T extends FooComponentType>(
{FooComponent}:{FooComponent:T}
):JsxIfHasClassNameProps<T>
{
return FooComponent({className:"my-added-class"}) as JsxIfHasClassNameProps<T>
}
I would think that would work, but it doesn't. Sort of... It doesn't work if I call CheckedWrapperComponent
using JSX, but it does work if I call it as a normal function with arguments, namely:
// this type is JSX.Element as expected
const cjsx1 = <CheckedWrapperComponent FooComponent={ComponentWClass} />
// this type is also JSX.Element, but I want it to be never
const cjsx2 = <CheckedWrapperComponent FooComponent={ComponentNoClass} />
// this type is never as expected and desired
const cjsx3 = CheckedWrapperComponent({FooComponent:ComponentNoClass})
Can anyone explain what is going on here and how I can get the behavior I want?
Here is a sandbox link.
(BTW, I don't think this changes anything, but I had to call FooComponent
as a function inside WrapperComponent
to get around an error TS was giving me regarding the Intrinsic attributes it applies to JSX. I'm open to a better way to do that, but it's not a huge priority as that implementation is all hidden from the consumer. By contrast I can't reasonable expect consumers not to call CheckedWrapperComponent
using JSX)
Edit
Just to clarify, my question is not about how to solve the contravariance issue generally. It's more specifically about how to solve it a way that is compatible with JSX. As noted in the second and third examples above:
// this type is also JSX.Element, but I want it to be never
const cjsx2 = <CheckedWrapperComponent FooComponent={ComponentNoClass} />
// this type is never as expected and desired
const cjsx3 = CheckedWrapperComponent({FooComponent:ComponentNoClass})
I can solve the issue if I pass in the component as a "normal" function argument. My question is why that stops working when I pass it in to JSX as a prop--and can this be achieved?
(Also, as noted in the comments, calling a functional component directly as is done in my examples is not the recommended way to do a non-JSX call, but I don't think that's relevant to my question which is really about TS. The same behavior occurs if you, properly, use React.createElement
)
Solution
type FooComponentType = (props: { className: string }) => JSX.Element;
function wrapComponent<C extends FooComponentType extends C ? any : never>(
Wrapped: C
) {
return React.createElement(Wrapped, { className: "my-added-class" });
}
function ComponentNoClass(props: {}) {
const [state, setState] = React.useState(0);
return (
<div onClick={() => setState((x) => x + 1)}>
my classname is... oops I don't have a classname. [{state}]
</div>
);
}
function ComponentWClass(props: { className: string }) {
const [state, setState] = React.useState(0);
return (
<div onClick={() => setState((x) => x + 1)}>
my classname is {props.className}. [{state}]
</div>
);
}
function ComponentWClassPlus(props: { className: string; foo: number }) {
const [state, setState] = React.useState(0);
return (
<div onClick={() => setState((x) => x + 1)}>
my classname is {props.className}. [{state}]
</div>
);
}
export default function App() {
const [state, setState] = React.useState(0);
const jsx1 = wrapComponent(ComponentNoClass); // not fine
const jsx2 = wrapComponent(ComponentWClass); // fine
const jsx3 = wrapComponent(ComponentWClassPlus); // fine
return (
<div>
{jsx1}
{jsx2}
{jsx3}
<button onClick={() => setState((x) => x + 1)}>
increment outer count. [{state}]
</button>
</div>
);
}
What's going on here?
wrapComponent()
is basically a "higher order component" function, that returns some rendered JSX from some component function/class that you call it with.
return React.createElement(Wrapped, { className: "my-added-class" });
The special sauce that gives you the type checking you crave is this: C extends FooComponentType extends C ? any : never
.
It essentially says, "generic type C
extends the conditional type (does FooComponentType
extend the generic type C
? If so, this type is any
, otherwise it is never
)".
It's a sort of counterintuitive way to get around the covariant issue you mentioned earlier, using a conditional type + never
to assert the type. It feels circular somehow (C extends FooComponentType extends C
???) but I guess because of the way TypeScript evaluates the conditional type from the inside out, it's fine?
You were pretty close with your implementation of CheckedWrapperComponent
, but this way you cut out that middle man and only need to use the higher order component function.
Answered By - jered
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.