DEV Community

Discussion on: Polymorphic React Button Component in Typescript

Collapse
mcapoz profile image
Mae Capozzi

Great work on this Anthony! This is a gnarly problem, and your approach really helped my team get this working in a more elegant way than we had done it before.

I've got a couple of suggestions to improve this that will help you to get rid of the type guards and allow support for forwardRefs.

Suggestion #1
You should consider replacing JSX.IntrinsicElements['button'] with React.ButtonHTMLAttributes<HTMLButtonElement> and JSX.IntrinsicElements['a'] with React.AnchorHTMLAttributes<HTMLAnchorElement>. This will allow you to support forwardRefs.

Suggestion #2
You don't need the type guards if you spread the props inside of if statements where the TypeScript compiler knows the value of the as prop.

if (props.as === 'externalLink') {
  // Now TypeScript can infer that the rest props are all for an externalLink.
  const {as, ...rest} = props;
}
Enter fullscreen mode Exit fullscreen mode

Here it is all together.

import * as React from 'react'
import { Link } from 'react-router-dom'
import type { LinkProps } from 'react-router-dom'

type BaseProps = {
  children: React.ReactNode
  className?: string
  styleType: 'primary' | 'secondary' | 'tertiary'
}

type ButtonAsButton = BaseProps &
  Omit< React.ButtonHTMLAttributes<HTMLButtonElement>, keyof BaseProps> & {
    as?: 'button'
  }

type ButtonAsUnstyled = Omit<ButtonAsButton, 'as' | 'styleType'> & {
  as: 'unstyled'
  styleType?: BaseProps['styleType']
}

type ButtonAsLink = BaseProps &
  Omit<LinkProps, keyof BaseProps> & {
    as: 'link'
  }

type ButtonAsExternal = BaseProps &
  Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof BaseProps> & {
    as: 'externalLink'
  }

type ButtonProps =
  | ButtonAsButton
  | ButtonAsExternal
  | ButtonAsLink
  | ButtonAsUnstyled

export function Button(props: ButtonProps): JSX.Element {
  const allClassNames = `${styleType ? styleType : ''} ${
    className ? className : ''
  }`

  if (rest.as === 'link') {
    const {allClassNames, ...rest} = props;
    return <Link className={allClassNames} {...rest} />
  } else if (as === 'externalLink') {
    const {allClassNames, ...rest} = props
    return (
      <a
        className={allClassNames}
        // provide good + secure defaults while still allowing them to be overwritten
        target='_blank'
        rel='noopener noreferrer'
        {...rest}
      >
        {rest.children}
      </a>
    )
  } else if (as === 'unstyled') {
    const {className, ...rest} = props
    return <button className={className} {...rest} />
  } else {
    const {allClassNames, ...rest} = props
    return <button className={allClassNames} {...rest} />
  }

  throw new Error('could not determine the correct button type')
}

type OmitFromTypes = 'className' | 'styleType' | 'as'
Enter fullscreen mode Exit fullscreen mode