Typing Polymorphic React Components with Generics
Polymorphic components adapt their behavior based on the element type passed as a prop, without duplicating code. Using TypeScript generics, you can build a single button component that renders as a <button>, an <a>, or a Next.js <Link> while preserving the correct prop types for each element. This pattern is fundamental to design systems that remain flexible across frameworks and use cases.
Understanding Polymorphism in React
A polymorphic component accepts a prop that controls what it renders. Without generics, you lose type safety:
// Without generics—no type safety
interface FlexibleButtonProps {
children: React.ReactNode;
as?: string;
[key: string]: any; // Too broad!
}
const FlexibleButton: React.FC<FlexibleButtonProps> = ({ as = 'button', ...props }) => {
const Component = as as React.ElementType;
return <Component {...props}>{props.children}</Component>;
};
// Calling it—no type checking on props
<FlexibleButton as="a" href="/home">Link</FlexibleButton> // okay
<FlexibleButton as="a" onClick={() => {}}>Bad</FlexibleButton> // no error, but wrong!
This works but loses type safety. The onClick handler is invalid for <a>, but TypeScript doesn't know.
Polymorphic Components with Generics
Use TypeScript generics to type polymorphic components correctly. The component accepts a generic element type parameter and infers the correct props for that element:
// Box.tsx
import React from 'react';
interface BoxProps<T extends React.ElementType> {
as?: T;
children?: React.ReactNode;
className?: string;
}
type BoxComponent = <T extends React.ElementType = 'div'>(
props: BoxProps<T> & React.ComponentPropsWithoutRef<T>
) => React.ReactElement | null;
const Box: BoxComponent = React.forwardRef(
({ as: Component = 'div', children, className, ...props }, ref) => (
<Component ref={ref} className={className} {...props}>
{children}
</Component>
)
);
Box.displayName = 'Box';
export default Box;
Now callers get correct prop types:
// Valid:
<Box as="div" className="container">Hello</Box>
<Box as="button" onClick={() => console.log('clicked')}>Click</Box>
<Box as="a" href="/home">Link</Box>
// Type error: <div> doesn't accept onClick
<Box as="div" onClick={() => {}}>Bad</Box>
// Type error: <a> doesn't accept form
<Box as="a" form="myForm">Bad</Box>
The React.ComponentPropsWithoutRef<T> type extracts all valid props for the element type T, ensuring only correct props are allowed.
Building a Polymorphic Button Component
Here's a practical polymorphic button that works as a button, link, or Next.js link:
// Button.tsx
import React from 'react';
interface BaseButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
className?: string;
}
interface ButtonProps<T extends React.ElementType = 'button'>
extends BaseButtonProps {
as?: T;
}
type ButtonComponent = <T extends React.ElementType = 'button'>(
props: ButtonProps<T> & React.ComponentPropsWithoutRef<T>
) => React.ReactElement | null;
const Button: ButtonComponent = React.forwardRef(
(
{
as: Component = 'button',
variant = 'primary',
size = 'medium',
disabled = false,
children,
className,
...props
},
ref
) => {
const sizeClass = { small: 'px-2 py-1 text-sm', medium: 'px-4 py-2', large: 'px-6 py-3 text-lg' }[size];
const variantClass = {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-300 text-black',
}[variant];
return (
<Component
ref={ref}
className={`btn ${variantClass} ${sizeClass} ${className || ''}`}
disabled={disabled}
{...props}
>
{children}
</Component>
);
}
);
Button.displayName = 'Button';
export default Button;
Usage with different element types:
// As a button with onClick
<Button onClick={() => console.log('clicked')}>Click Me</Button>
// As an anchor with href
<Button as="a" href="/login">Login</Button>
// As a Next.js Link
<Button as={Link} href="/dashboard">Dashboard</Button>
// Type error: button doesn't accept href
<Button href="/bad">Bad</Button>
// Type error: <a> doesn't accept type="submit"
<Button as="a" type="submit">Bad</Button>
Each element type brings its valid props. TypeScript enforces that you only use props valid for the chosen element.
Polymorphic Props with Union Types
For components that don't support all elements, constrain the as prop with a union:
// Link.tsx
interface BaseProps {
children: React.ReactNode;
className?: string;
}
type LinkAs = 'a' | 'button';
interface LinkProps<T extends LinkAs = 'a'> extends BaseProps {
as?: T;
}
type LinkComponent = <T extends LinkAs = 'a'>(
props: LinkProps<T> & React.ComponentPropsWithoutRef<T>
) => React.ReactElement | null;
const Link: LinkComponent = React.forwardRef(
({ as: Component = 'a', children, className, ...props }, ref) => (
<Component ref={ref} className={className} {...props}>
{children}
</Component>
)
);
Link.displayName = 'Link';
export default Link;
// Valid:
<Link as="a" href="/page">Page</Link>
<Link as="button" onClick={() => {}}>Action</Link>
// Error: div is not in the union
<Link as="div">Bad</Link>
Constraining as to a union of specific elements makes the component's intent clear.
Extracting Props from Polymorphic Components
To infer the props accepted by a polymorphic component instance, create a helper type:
// Extract the props for a given element type
type PolymorphicProps<T extends React.ElementType> = React.ComponentPropsWithoutRef<T>;
// Usage
type AnchorProps = PolymorphicProps<'a'>; // { href?: string; target?: string; ... }
type ButtonProps = PolymorphicProps<'button'>; // { onClick?: ...; type?: 'submit' | 'reset' | 'button'; ... }
Polymorphic Components with Composition
Combine polymorphic patterns with other composition techniques:
// Stack.tsx
interface StackProps<T extends React.ElementType = 'div'> {
as?: T;
gap?: number;
direction?: 'row' | 'column';
children?: React.ReactNode;
}
type StackComponent = <T extends React.ElementType = 'div'>(
props: StackProps<T> & React.ComponentPropsWithoutRef<T>
) => React.ReactElement | null;
const Stack: StackComponent = React.forwardRef(
(
{
as: Component = 'div',
gap = 1,
direction = 'column',
children,
style,
...props
},
ref
) => {
const gapStyle = { gap: `${gap * 0.5}rem` };
const directionStyle = { flexDirection: direction };
return (
<Component
ref={ref}
style={{ display: 'flex', ...directionStyle, ...gapStyle, ...style }}
{...props}
>
{children}
</Component>
);
}
);
Stack.displayName = 'Stack';
export default Stack;
Key Takeaways
- Polymorphic components accept an
asprop that controls the element type, enabling flexible reuse. - Use
React.ComponentPropsWithoutRef<T>to extract valid props for a given element type. - Combine a generic
as?: T extends React.ElementTypeparameter with intersection types to enforce type safety on all valid props. - Forward refs with
React.forwardRefto expose the underlying element's ref to parents. - Constrain
asto a union of specific elements when the component logically supports only certain types.
Frequently Asked Questions
Why use ComponentPropsWithoutRef instead of ComponentProps?
ComponentPropsWithoutRef excludes the ref prop from the type, which you handle separately with React.forwardRef. ComponentProps includes ref, causing type conflicts. Use ComponentPropsWithoutRef with forwardRef for a clean pattern.
Can I use generics for class components?
Yes, but the pattern is more complex. Functional components with forwardRef are preferred in modern React. Class components can use generics on the props, but they don't support forwardRef as elegantly.
What does <T extends React.ElementType = 'div'> mean?
It declares a generic type parameter T that must be a React element type (string like 'div', or a component). The = 'div' provides a default—if as is omitted, T defaults to 'div'. This makes as optional while maintaining type safety.
How do I type a component that only accepts button or anchor elements?
Use a union type: interface Props<T extends 'button' | 'a' = 'button'> { as?: T; }. This restricts T to two specific values, and TypeScript enforces that as is one of them.
Is there a simpler way to write polymorphic components?
For many use cases, yes—accept a component as a prop and spread props directly. The full generic pattern is complex but essential for large design systems where type safety prevents bugs. For smaller projects, simpler approaches may suffice.