Reputation: 475
I have an enum with two members,
enum Step {
ONE = "StepOne",
TWO = "StepTwo"
}
each of which are assigned to a property, step
in a discriminating union
type Props =
| { type: Step.TWO; headline: string }
| { type: Step.ONE; headline: string[] };
There is a generic component that takes props of either shape in the union and returns a matching component from a "componentMap", object
const Component = <T extends Step>(props: Extract<Props, { step: T }>) => {
const compMap = { StepOne, StepTwo };
// This works but I don't want to need to cast Comp 😅
// const Comp = compMap[props.type] as React.ComponentType<any>;
const Comp = compMap[props.step];
return <Comp {...props} />;
};
If I cast compMap[props.step]
as React.ComponentType<any>
(or as React.FunctionComponent<any>
etc) I don't have a type error, but I'd like to not need to do this.
The specific error I get when not casting compMap[props.step]
is:
Type '{ step: T; } & { step: Step.TWO; headline: string; }' has no properties in common with type 'IntrinsicAttributes'
So theres a mismatch happening somewhere...
Is there a way to correctly type this Component
function so that it can infer the appropriate component it is meant to return?
Here is a codesandbox with an example of the implementation for context
Upvotes: 0
Views: 393
Reputation: 329598
I don't see why you've chosen to make the call signature of Component
generic; the input is of type Props
and the output is of type JSX.Element
. Since the T
parameter appears only once, there's no useful distinction from the caller's side between <T extends Step>(props: Extract<Props, { step: T }>) => ...
and (props: Props) => ...
.
You might hope that the implementation of Component
would find the extra type information useful, but unfortunately it does not. Nothing stops T
from being the full union Step
, and therefore props
could be of type Extract<Props, {step: Step}>
, i.e, just Props
. So T
is not being helpful in the implementation either. (See microsoft/TypeScript#24085 and microsoft/TypeScript#27808 for open feature requests related to narrowability of generic type parameters.)
Therefore, I'll switch to a non-generic function signature (props: Props) => JSX.Element
from here on out.
I think the only way you will be able avoid a type assertion is by explicitly walking the compiler through the different possible Props
cases. This will allow control flow analysis to take place:
const Component = (props: Props) => {
return props.step === Step.ONE ? <StepOne {...props} /> : <StepTwo {...props} />;
};
That is, unfortunately, redundant. It certainly doesn't scale if you've got StepNinetySeven
.
It would be nice if the compiler could just understand that compMap[props.step]
will always be able to accept props
; but this would require support for what I've been calling correlated types, and there isn't any such support now. See microsoft/TypeScript#30581 for a feature request around this. Without being able to keep track of correlations between different union-typed values, the compiler only sees that compMap[props.step]
is of type StepOne | StepTwo
while props
is of type { type: Step.TWO; headline: string } | { type: Step.ONE; headline: string[] }
. The compiler treats these as uncorrelated or independent; for all it understands, compMap[props.step]
might be StepOne
while props
is of type {type: Step.TWO; headline: string }
. So you get an error.
If you don't want redundant code, you currently have no choice but to use a type assertion to tell the compiler not to fret about the "wrong" pairings of compMap[props.step]
and props
. This is giving up some type safety (the compiler no longer will catch certain errors). You can do this with any
, as you've done, which is the easiest. More complicated is to take a union-of-functions and narrow it to a single function that accepts a union-of-its-parameters:
type UnifyFunctions<F extends (...args: any) => any> =
(...x: Parameters<F>) => ReturnType<F>;
const Component = (props: Props) => {
const compMap = { StepOne, StepTwo };
const Comp = compMap[props.step] as
UnifyFunctions<typeof compMap[keyof typeof compMap]>;
return <Comp {...props} />;
}
That pretends Comp
will be happy with either of the two types of props
, and thus {...props}
is accepted; it's less unsafe than any
, at least.
Upvotes: 3