Search code examples
javascriptreactjstypescript

How can I implement a "as" prop with TypeScript while passing down the props?


I am building a library of components and I need some of them to have a customizable tag name. For example, sometimes what looks like a <button> is actually a <a>. So I would like to be able to use the button component like so:

<Button onClick={onClick}>Click me!</Button>
<Button as="a" href="/some-url">Click me!</Button>

Ideally, I would like the available props to be inferred based on the "as" prop:

// Throws an error because the default value of "as" is "button",
// which doesn't accept the "href" attribute.
<Button href="/some-url">Click me!<Button>

We might need to pass a custom component as well:

// Doesn't throw an error because RouterLink has a "to" prop
<Button as={RouterLink} to="/">Click me!</Button>

Here's the implementation, without TypeScript:

function Button({ as = "button", children, ...props }) {
  return React.createElement(as, props, children);
}

So, how can I implement a "as" prop with TypeScript while passing down the props?

Note: I am basically trying to do what styled-components does. But we are using CSS modules and SCSS so I can't afford adding styled-components. I am open to simpler alternatives, though.


Solution

  • New answer

    I recently came across Iskander Samatov's article React polymorphic components with TypeScript in which they share a more complete and simpler solution:

    import * as React from "react";
    
    interface ButtonProps<T extends React.ElementType> {
      as?: T;
      children?: React.ReactNode;
    }
    
    function Button<T extends React.ElementType = "button">({
      as,
      ...props
    }:
      ButtonProps<T>
      & Omit<React.ComponentPropsWithoutRef<T>, keyof ButtonProps<T>>
    ) {
      const Component = as || "button";
      return <Component {...props} />;
    }
    

    Typescript playground

    Update 4 Apr 2024: I stumbled upon a new article: React "as" Prop Using TypeScript.

    Old answer

    I spent some time digging into styled-components' types declarations. I was able to extract the minimum required code, here it is:

    import * as React from "react";
    import { Link } from "react-router-dom";
    
    type CustomComponentProps<
      C extends keyof JSX.IntrinsicElements | React.ComponentType<any>,
      O extends object
    > = React.ComponentPropsWithRef<
      C extends keyof JSX.IntrinsicElements | React.ComponentType<any> ? C : never
    > &
      O & { as?: C };
    
    interface CustomComponent<
      C extends keyof JSX.IntrinsicElements | React.ComponentType<any>,
      O extends object
    > {
      <AsC extends keyof JSX.IntrinsicElements | React.ComponentType<any> = C>(
        props: CustomComponentProps<AsC, O>
      ): React.ReactElement<CustomComponentProps<AsC, O>>;
    }
    
    const Button: CustomComponent<"button", { variant: "primary" }> = (props) => (
      <button {...props} />
    );
    
    <Button variant="primary">Test</Button>;
    <Button variant="primary" to="/test">
      Test
    </Button>;
    <Button variant="primary" as={Link} to="/test">
      Test
    </Button>;
    <Button variant="primary" as={Link}>
      Test
    </Button>;
    

    TypeScript playground

    I removed a lot of stuff from styled-components which is way more complex than that. For example, they have some workaround to deal with class components which I removed. So this snippet might need to be customized for advanced use cases.