Last active
December 5, 2025 10:46
-
-
Save lilBunnyRabbit/f410653edcacec1b12cb44af346caddb to your computer and use it in GitHub Desktop.
Polymorphic React Component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const PolyComponent = createPolymorphic< | |
| { | |
| download?: boolean; | |
| className?: string; | |
| children?: React.ReactNode; | |
| }, | |
| { | |
| value: number; | |
| } | |
| >((Component, { value, className, ...props }) => ( | |
| <Component className={`bg-red-500 text-blue-500 ${className}`} {...props}> | |
| Value is {value}{props.download ? "(click to download)" : ""} | |
| </Component> | |
| )); | |
| const InvalidComponent = ({ foo }: { foo: string }) => foo; | |
| const ValidComponent = ({ | |
| href, | |
| ...props | |
| }: { | |
| href: string; | |
| download?: boolean; | |
| className?: string; | |
| children?: React.ReactNode; | |
| }) => <a href={href} {...props} />; | |
| export function Test() { | |
| return ( | |
| <> | |
| <PolyComponent as={ValidComponent} href="/my-file.pdf" value={123} /> | |
| <PolyComponent | |
| as="a" | |
| value={123} | |
| // Correctly inferred as HTMLAnchorElement | |
| onClick={(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => | |
| console.log('clicked', e) | |
| } | |
| // You can also pass required properties to the component. | |
| className="bg-blue-500" | |
| /> | |
| {/* Invalid components */} | |
| <PolyComponent as={InvalidComponent} value={123} foo="123" /> | |
| {/* Type '({ foo }: { foo: string; }) => string' is not assignable to type 'never'. */} | |
| <PolyComponent as="div" value={123} /> | |
| {/* Type 'string' is not assignable to type 'never'. */} | |
| {/* Missing props components */} | |
| <PolyComponent as={ValidComponent} value={123} /> | |
| {/* Property 'href' is missing in type {...} */} | |
| <PolyComponent as={ValidComponent} bar="123" /> | |
| {/* Property 'bar' does not exist on type {...} */} | |
| {/* Invalid props */} | |
| <PolyComponent as={ValidComponent} value="123" bar={123} /> | |
| {/* Type 'string' is not assignable to type 'number'. */} | |
| </> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // A way more expensive check. Not used in this implementation. | |
| // type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y | |
| // ? 1 | |
| // : 2 | |
| // ? true | |
| // : false; | |
| // type IsEqualProps<Source, Compare> = keyof Source extends keyof Compare | |
| // ? Equal< | |
| // Source, | |
| // { | |
| // [K in keyof Source as K extends keyof Compare ? K : never]: Compare[K]; | |
| // } | |
| // > | |
| // : false; | |
| /** | |
| * Helper type to allow for polymorphic components to be used with any element. | |
| */ | |
| type PolymorphicElement<T = any> = | |
| | keyof React.JSX.IntrinsicElements | |
| | React.JSXElementConstructor<T>; | |
| /** | |
| * This is a prop level check since as could either be a component or an element reference. | |
| * | |
| * @template TElementProps - The props of the target element/component. | |
| * @template TProps - Props that must be compatible with the target element. | |
| * @template TAdditional - Additional props used by the render function. | |
| */ | |
| type ValidPolymorphicProps<TElementProps, TProps, TAdditional> = | |
| keyof TProps extends keyof TElementProps | |
| ? Partial<TProps> & | |
| TAdditional & | |
| Omit<TElementProps, keyof TProps | keyof TAdditional | 'as'> | |
| : never; | |
| /** | |
| * We dont list only valid elements due to performance reasons. The element validation is done | |
| * by checking if the required props are a subset of the element props. | |
| * | |
| * If the check fails we return `never` (due to performance reasons) so that it invalidates the | |
| * whole component. | |
| * | |
| * @template TElement - The element type to render (intrinsic element string or component). | |
| * @template TProps - Props that must be compatible with the target element. | |
| * @template TAdditional - Additional props used by the polymorphic component. | |
| */ | |
| type PolymorphicProps< | |
| TElement extends PolymorphicElement, | |
| TProps, | |
| TAdditional, | |
| > = { | |
| /** | |
| * `never` will override this property if the element props are not valid. | |
| */ | |
| as: TElement; | |
| } & ValidPolymorphicProps<React.ComponentProps<TElement>, TProps, TAdditional>; | |
| /** | |
| * Type representing a polymorphic component render function. | |
| * | |
| * @template TProps - Props that the render function will pass to the component. | |
| * @template TAdditional - Additional props used by the render function. | |
| */ | |
| type PolymorphicRender<TProps, TAdditional> = < | |
| TComponent extends PolymorphicElement, | |
| >( | |
| props: PolymorphicProps<TComponent, TProps, TAdditional>, | |
| ) => React.ReactNode | Promise<React.ReactNode>; | |
| /** | |
| * Creates a polymorphic component that can render as any valid React element or component. | |
| * | |
| * @template TProps - Props that the render function will pass to the component. | |
| * @template TAdditional - Additional props used by the render function. | |
| */ | |
| export function createPolymorphic< | |
| TProps, | |
| TAdditional = Record<string, unknown>, | |
| TRenderProps = Partial<TProps> & TAdditional & Record<string, unknown>, | |
| >( | |
| render: ( | |
| Component: PolymorphicElement<TProps>, | |
| props: TRenderProps, | |
| ) => React.ReactElement, | |
| ): PolymorphicRender<TProps, TAdditional> { | |
| return ({ as: Component, ...props }) => { | |
| // It's more performant to just type cast here since we know the props are valid. | |
| return render(Component, props as TRenderProps); | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment