skip to Main Content

I have made a Button React Component:

function Button({ text, Icon, iconProps, ...buttonProps }: ButtonProps)

The props must have this structure:

  • Only a text
  • Only an Icon
  • An Icon with iconProps (the iconProps can only be passed if the Icon is also present)
  • A text, an Icon
  • A text, an Icon and iconProps
  • It cannot be empty, it has to either have at least a text or an Icon

If I make all the props optional I loose the type safety I am aiming to. I’ve attempted to create an interface for each scenario and make a union of all of them, but it is not working and doesn’t seem like an elegant minimalistic solution:

interface ButtonOnlyText extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  text: string;
}

interface ButtonOnlyIcon extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
}

interface ButtonIconWithProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
  iconProps: React.SVGProps<SVGSVGElement>;
}

interface ButtonTextAndIcon extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  text: string;
  Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
}

interface ButtonComplete extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  text: string;
  Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
  iconProps: React.SVGProps<SVGSVGElement>;
}

type ButtonProps = ButtonOnlyText | ButtonOnlyIcon | ButtonIconWithProps | ButtonTextAndIcon | ButtonComplete

declare function Button({ text, Icon, iconProps, ...buttonProps }: ButtonProps): React.FC;

This is the link to Typescript playground with the code for seeing the error.

2

Answers


  1. You can reduce your set of interfaces as { a: A; } | { a: A; b: B; } is { a: A; b?: B; }:

    interface ButtonOnlyText extends React.ButtonHTMLAttributes<HTMLButtonElement> {
      text: string;
    }
    
    interface ButtonOnlyIcon extends React.ButtonHTMLAttributes<HTMLButtonElement> {
      Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
      iconProps?: React.SVGProps<SVGSVGElement>;
    }
    
    interface ButtonTextAndIcon extends React.ButtonHTMLAttributes<HTMLButtonElement> {
      text: string;
      Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
      iconProps?: React.SVGProps<SVGSVGElement>;
    }
    

    Then you can’t declare Button as

    function Button({ text, Icon, iconProps, ...buttonProps }: ButtonProps)
    

    Simply because there are interfaces in your disjunction that don’t have the props you’re trying to access. Typescript will tell you that these properties are not defined.

    All you can do is declare it as

    function Button(props: ButtonProps)
    

    And then have some code that’d work like this:

    const buttonProps = restrict(props).toAllBut(["text", "Icon", "iconProps"]);
    

    And query for the props you want to access like this:

    if ("text" in props && "Icon" in props) // ButtonTextAndIcon
    

    And handle all cases as you please.

    You might want to expose these components instead:

    declare function TextButton({ text, ...buttonAttributes }: ButtonOnlyText): React.FC;
    declare function IconButton({ Icon, iconProps, ...buttonAttributes }: ButtonOnlyIcon): React.FC;
    declare function TextIconButton({ text, Icon, iconProps, ...buttonAttributes }: ButtonTextAndIcon): React.FC;
    

    That all delegate to the main Button.

    Login or Signup to reply.
  2. My experience trying to make this kind of complicated types is that it rarely ends well, it almost always ends up with types that are pretty difficult to use, so I would instead try to simplify things.

    It seems like you have two different problems you want to solve:

    • at least one of text and icon should be present
    • iconProps should only be present when Icon is present

    For the first problem, I’m not sure how important it actually is. The users of your Button component will end up with a weird-looking empty button, which they will surely notice pretty quickly and fix by adding either a text or an icon (or both). So I would simply ignore this problem and make both optional.

    For the second problem, instead of expecting a React component as the icon, you could accept instead some arbitrary JSX:

    // Before
    <Button Icon={MyIcon} iconProps={{someProp: value}} />
    
    // After
    <Button icon={<MyIcon someProp={value}/>} />
    

    This way you simply need to accept a prop icon?: ReactNode and you don’t need to care about iconProps at all. It is up to the caller to pass the right JSX.

    You could even go one step further and simply accept children: ReactNode instead of both text and icon, which gives even more flexibility to the users of this component. Maybe someone wants to put the icon on the other side, or have two icons, or have a png icon, or have some bold text, etc.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search