Search code examples
reactjstypescriptinterfacecomponentsreact-typescript

react-typescript - How do I allow a specific component type to pass typescript check?


I'm relatively new to typescript, although having a long experience with react (and prop-types).

I've encountered a problem with typing my component, if there's another component as a prop. I already have a Button component with a defined type.

Now the problem is, when I create a new component ButtonGroup having a children prop that only should allow Button components to be rendered. I'm comparing the child's props to the Button type props - this means that I can pass there another component like Input if I don't break a button prop - like having <Input id="input-id" /> is fine for typescript, but I don't want to allow that, because it's a different type of component.

I've successfully played with the component.type.displayName, but Typescript doesn't like this solution at all.

import { ReactElement, Children } from 'react'

type buttonVariantType = 'primary' | 'secondary' | undefined
type buttonProps = {
  id?: string
  variant?: buttonVariantType 
  children: ReactNode
  ...
}

const Button =({ children, id, variant = 'secondary' }) => {
  ...
  return <button ...>{children}</button>
}


interface buttonGroupProps {
  children: ReactElement<buttonProps>[]
  variant?: buttonVariantType 
}


const ButtonGroup = ({ children, variant }: buttonGroupProps) => {
  return (
    <div>
      {Children.map(children, (child) => {
        // TS2339: Property 'displayName' does not exist on type 'string | JSXElementConstructor<any>'
        if (child?.type.displayName !== 'Button') {
          console.log(child.type.displayName, 'not a button')
          throw Error('not a button') // ugly workaround, still unacceptable by typescript
        }
        return (
          <Button {...child?.props} variant={variant ?? child.props.variant}>
            {child.props.children}
          </Button>
        )
      })}
    </div>
  )
}

Now if I put another component as a children into ButtonGroup, the typescript is okay with that and I don't want that.

<ButtonGroup variant="danger">
  <Input id="123" /> 
  <Button variant="primary" id="primary">
    Primary button
  </Button>
  <Button variant="secondary" id="secondary">
    Secondary button
  </Button>
</ButtonGroup>

Solution

  • It's not possible to make it compile time safe,reason for that is, when you declare any component with the jsx tags, the type that's created through that contains any for the values in the type. Let me give you an example -

    import * as React from "react";
    
    type buttonProps = {
        id?: string;
        variant?: 'primary' | 'secondary';
        children?: React.ReactNode;
    }
    
    const Button = ({ children, id, variant = 'secondary' }: buttonProps) => {
        return <button>{children}</button>
    }
    
    const div = () => <div></div>;
    const input = () => <input></input>;
    const Btn = () => <Button></Button>;
    
    type Button = React.ReactElement<buttonProps, React.JSXElementConstructor<buttonProps>>;
    type Div = ReturnType<typeof div>;
    type Input = ReturnType<typeof input>;
    type Btn = ReturnType<typeof Btn>;
    
    type ExpandedButton = { [Property in keyof Button]: Button[Property] }
    //   ^? type ExpandedButton = { type: React.JSXElementConstructor<buttonProps>; props: buttonProps; key: React.Key | null; }
    
    type ExpandedDiv = { [Property in keyof Div]: Div[Property] };
    //   ^? type ExpandedDiv = { type: any; props: any; key: React.Key | null; }
    
    type ExpanedInput = { [Property in keyof Input]: Input[Property] };
    //   ^? type ExpanedInput = { type: any; props: any; key: React.Key | null; 
    
    type ExpandedBtn = { [Property in keyof Btn]: Btn[Property] };
    //   ^? type ExpanedBtn = { type: any; props: any; key: React.Key | null;
    
    type Assignable = (ExpandedButton extends (ExpandedDiv | ExpanedInput | ExpandedBtn | Div | Input | Btn) ? true : false) | ((ExpandedDiv | ExpanedInput | ExpandedBtn | Div | Input | Btn) extends ExpandedButton ? true : false);
    //   ^? type Assignable = true
    

    Which goes to show, any jsx element can be assigned to the other. As for a runtime error, you got it nearly right, the check that you should be making is to see if the component that's the child is actually the Button component through reference equality check, like this -

    import { ReactElement, Children } from 'react'
    
    type buttonVariantType = 'primary' | 'secondary' | undefined
    type buttonProps = {
      id?: string
      variant?: buttonVariantType 
      children?: React.ReactNode
    }
    
    const Button =({ children, id, variant = 'secondary' }: buttonProps) => {
      return <button>{children}</button>
    }
    
    
    interface buttonGroupProps {
      children: ReactElement<buttonProps>[]
      variant?: buttonVariantType 
    }
    
    
    const ButtonGroup = ({ children, variant }: buttonGroupProps) => {
      return (
        <div>
          {Children.map(children, (child) => {
    
            if (child?.type !== Button ) {
              throw Error('not a button')
            }
    
            return (
              <Button {...child?.props} variant={variant ?? child.props.variant}>
                {child.props.children}
              </Button>
            )
          })}
        </div>
      )
    }