Search code examples
reactjstypescripttsx

Cannot find a way to do conditional props


I currently have two components:

export interface MediaCardProps {
  alt: string;
  src: string;
}

export const MediaCard = ({ alt, src }: MediaCardProps) => {
  ...
};

and

export interface TextCardProps {
  isLoaded: boolean;
  children: ReactNode;
}

export const TextCard = ({ children, isLoaded = false }: TextCardProps) => {
   ...
};

I would like to create a parent component like this:

type Props = ({ isMedia: false } & TextCardProps) | ({ isMedia: true } & MediaCardProps);

export const Card = ({ isMedia = false, ...args }: Props) => {
  if (isMedia) return <MediaCard {...(args as MediaCardProps)} />;

  return <TextCard {...(args as TextCardProps)} />;
};

The goal here is that when isMedia is set to true, we only see the MediaCardProps, otherwise, we see the TextCardProps.

But doing so, I have an issue when calling it. I call it this way:

<Card isLoaded={!!data.title}>
  <h1 className='text-2xl font-bold'>{data.title}</h1>
</Card>

and I have this error Type '{ children: Element; isLoaded: boolean; }' is not assignable to type 'IntrinsicAttributes & Props'. Property 'isMedia' is missing in type '{ children: Element; isLoaded: boolean; }' but required in type '{ isMedia: false; }'.

I managed to fix it by changing my parent Props to this:

type Props = { isMedia?: false; args: TextCardProps } | { isMedia: true; args: MediaCardProps };

but I don't want the args prop.

Many thanks if you can help me fix this !


Solution

  • Your Card component needs to infer information about the requested media type. This can be achieved by defining the Card component as a generic component that takes CardType as a generic parameter.

    The same logic applies to the boolean type, but for better flexibility, I replaced the boolean type with a union of types:

    type CardType = 'media' | 'text'
    

    Now, make the Card component generic:

    const Card = <TCardType extends CardType>(props: Props<TCardType>) => { ... }
    

    We pass TCardType as a generic type to the Props. To infer its actual value, we need to define a conditional logic inside our dynamic generic Props type.

    type Props<CardType> = { type?: CardType } & (CardType extends 'media' ? MediaCardProps : TextCardProps)
    

    The { type?: CardType } allows for the type to be optional (defaulting to 'text') and specifies the user-defined type. The type of this field will be inferred, allowing Props to provide the proper structure for the actual Card implementation (with isMedia boolean, you would check by T extends true instead).

    Lastly, inside the generic Card component, we only need to verify the kind of props we are dealing with. Based on this information, we're able to return the appropriate Card implementation:

    const Card = <TCardType extends CardType>(props: Props<TCardType>) => {
      if (isMediaProps(props)) {
        return <MediaCard {...props} />;
      }
      if (isTextProps(props)) {
        return <TextCard {...props} />;
      }
      return null
    };
    

    Here's a TS playground for you with a complete solution.