Skip to content

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.

Answer

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

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.