Spencer Pope
Spencer Pope

Reputation: 475

Typescript: Generic Component Discriminating Between Different Input Props

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

Answers (1)

jcalz
jcalz

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.


Playground link to code

Upvotes: 3

Related Questions