Component Polymorphism: Implementing Truly Type-Safe as Prop Patterns for Reusable Design System Components

Design systems live or die by one tension: consistency versus flexibility. Every team eventually hits the same wall. A Button component looks perfect until someone needs it to render as an anchor tag for navigation. A Text component works great until someone needs an h1 for SEO but the visual styling of a p. A Card needs to sometimes be a <div>, sometimes a <button>, and sometimes a router <Link>.

The naive fix — cloning the component, adding boolean props like isLink, or spreading dangerouslySetInnerHTML-style escape hatches — creates technical debt fast. The mature fix is component polymorphism: a single component that can render as different underlying elements or components while remaining fully type-safe.

This article walks through what polymorphic components are, why the as prop pattern became the industry standard, and how to implement it in TypeScript and React without falling into the common traps that make "type-safe" polymorphism secretly any in disguise.

What Is a Polymorphic Component?

A polymorphic component is a component that accepts a special prop — conventionally named as — which determines the underlying HTML element or component it renders. The component keeps its own internal styling, behavior, and accessibility logic, but delegates the actual rendered tag to whatever is passed through as.

Design systems like those built on Radix primitives, Chakra UI, Mantine, and Coinbase's CDS all rely on this pattern. The core idea is always the same: one component, many possible outputs, and — critically — type inference that changes based on what as is set to.

Consider a Box component:

tsx

<Box>Renders as a div by default</Box>
<Box as="section">Renders as a section</Box>
<Box as="button" onClick={handleClick}>Renders as a button with onClick typed correctly</Box>

When as="button", TypeScript should know that onClick receives a React.MouseEvent<HTMLButtonElement>, that disabled is a valid prop, and that href is not. When as="a", the inverse should be true. This is the crux of "truly type-safe" polymorphism — the types have to change shape based on the value of a prop, not just accept a loosely-typed union that quietly degrades to any.

Why Boolean Props and Component Cloning Fall Short

Before diving into the pattern itself, it's worth being explicit about why teams reach for polymorphism instead of simpler alternatives.

Boolean prop explosion. A component that grows isLink, isButton, renderAsHeading, and asExternalLink props over time becomes an unreadable decision tree. Each new use case adds another prop, and the combinations that don't make sense (isLink and isButton both true) are only preventable through runtime checks, not the type system.

Duplicated components. Copy-pasting Button into LinkButton solves the immediate problem but doubles the maintenance surface. A design token change, an accessibility fix, or a new variant now needs to be applied twice — and inevitably, one copy drifts from the other.

Loss of semantic HTML. Design systems are often criticized, fairly, for producing "div soup" — everything rendered as a generic <div> with heavy styling layered on top, regardless of its actual semantic role. This hurts accessibility and SEO. Polymorphism solves this directly: the same visually-styled Heading component can render as h1 on a landing page and h3 inside a card, without duplicating styles.

Polymorphism isn't a stylistic preference — it's what lets a design system stay both consistent (one source of truth for styling and behavior) and correct (proper semantic HTML per instance).

The Building Blocks: ElementType and Generics

TypeScript's type-safe polymorphism relies on three ingredients that React's own type definitions expose:

  1. React.ElementType — a type representing anything that can be used as a JSX tag: an intrinsic string like "div" or "button", or a custom component.
  2. Generic component functions — the component itself must be generic over the element type so its prop types can shift based on what's passed to as.
  3. React.ComponentPropsWithoutRef<T> / ComponentPropsWithRef<T> — utility types that extract the exact prop shape (including all valid HTML attributes) for whatever element type T currently is.

These types are documented directly in the TypeScript Handbook's generics reference and in React's TypeScript documentation, which is the authoritative source for how React recommends typing components, refs, and props in TypeScript projects.

Step 1: A Minimal Polymorphic Component

Start with the simplest possible version, without ref forwarding, to see the core mechanic clearly:

tsx

import { ElementType, ComponentPropsWithoutRef } from "react";

type BoxOwnProps<T extends ElementType> = {
  as?: T;
};

type BoxProps<T extends ElementType> = BoxOwnProps<T> &
  Omit<ComponentPropsWithoutRef<T>, keyof BoxOwnProps<T>>;

function Box<T extends ElementType = "div">({ as, ...props }: BoxProps<T>) {
  const Component = as || "div";
  return <Component {...props} />;
}

A few details matter here:

  • T extends ElementType = "div" gives the generic a default, so <Box> without an as prop still type-checks against div attributes.
  • Omit<ComponentPropsWithoutRef<T>, keyof BoxOwnProps<T>> strips out any attribute names that collide with the component's own custom props (like as itself), preventing type conflicts.
  • Because T is inferred from the as prop at the call site, <Box as="button" onClick={...}> and <Box as="a" href={...}> each get correctly scoped prop types.

Try passing href to <Box as="button"> and TypeScript will correctly reject it — that's the signal the pattern is actually working, rather than silently allowing anything.

Step 2: Adding Component-Specific Props

Real design system components aren't just pass-throughs — they have their own props like variant, size, or tone. These need to merge with the polymorphic element props without losing type safety on either side:

tsx

import { ElementType, ComponentPropsWithoutRef } from "react";

type ButtonOwnProps<T extends ElementType> = {
  as?: T;
  variant?: "primary" | "secondary" | "ghost";
  size?: "sm" | "md" | "lg";
};

type ButtonProps<T extends ElementType> = ButtonOwnProps<T> &
  Omit<ComponentPropsWithoutRef<T>, keyof ButtonOwnProps<T>>;

function Button<T extends ElementType = "button">({
  as,
  variant = "primary",
  size = "md",
  className,
  ...props
}: ButtonProps<T>) {
  const Component = as || "button";
  const classes = `btn btn-${variant} btn-${size} ${className ?? ""}`.trim();
  return <Component className={classes} {...props} />;
}

Now:

tsx

<Button variant="primary" onClick={() => {}}>Click me</Button>
<Button as="a" href="/docs" variant="ghost">Go to docs</Button>

onClick is valid and correctly typed in the first call (button semantics); href is valid in the second (anchor semantics). This is the practical payoff of the pattern — the design system exposes one component name, Button, but the type checker enforces correct usage per rendered tag.

Step 3: Ref Forwarding Without Breaking Polymorphism

Design system components almost always need ref forwarding — for focus management, measuring DOM nodes, or integrating with animation libraries. The complication is that the ref's type also needs to change based on as. A ref on <Button as="a"> should be HTMLAnchorElement, not HTMLButtonElement.

This requires a small amount of extra type plumbing, since forwardRef and generics don't naturally play well together in TypeScript (forwardRef types generics awkwardly by default):

tsx

import {
  ElementType,
  ComponentPropsWithoutRef,
  ComponentPropsWithRef,
  forwardRef,
  ReactElement,
} from "react";

type PolymorphicRef<T extends ElementType> = ComponentPropsWithRef<T>["ref"];

type ButtonOwnProps<T extends ElementType> = {
  as?: T;
  variant?: "primary" | "secondary" | "ghost";
};

type ButtonProps<T extends ElementType> = ButtonOwnProps<T> &
  Omit<ComponentPropsWithoutRef<T>, keyof ButtonOwnProps<T>>;

type ButtonComponent = <T extends ElementType = "button">(
  props: ButtonProps<T> & { ref?: PolymorphicRef<T> }
) => ReactElement | null;

const Button: ButtonComponent = forwardRef(function Button
  T extends ElementType = "button"
>(
  { as, variant = "primary", className, ...props }: ButtonProps<T>,
  ref?: PolymorphicRef<T>
) {
  const Component = as || "button";
  return (
    <Component
      ref={ref}
      className={`btn btn-${variant} ${className ?? ""}`.trim()}
      {...props}
    />
  );
}) as ButtonComponent;

The key move is defining ButtonComponent as a standalone generic call signature and then casting the forwardRef result to it. forwardRef's own type signature isn't generic-friendly, so without this cast, T would collapse to unknown or ElementType at every call site, defeating the entire purpose. This exact workaround is a well-known pattern in the React + TypeScript community and reflects how libraries such as Radix historically implemented their (now-deprecated in favor of asChild) polymorphic utilities.

Common Pitfalls That Silently Break Type Safety

Because the type gymnastics here are non-trivial, it's easy to end up with something that looks type-safe but isn't. Watch for these failure modes:

  • Leaking any through prop spreading. If ...props is typed as any anywhere in the chain (often from an untyped intermediate wrapper), every downstream type check is silently disabled. Run as const and explicit return types on wrapper functions to catch this.
  • Forgetting to Omit colliding keys. If a custom prop name matches an HTML attribute name (e.g., a color prop colliding with the SVG color attribute), failing to Omit it from the extracted ComponentPropsWithoutRef<T> creates ambiguous, often incorrect, merged types.
  • Over-widening the generic default. Defaulting T to ElementType instead of a specific tag (like "div") means TypeScript can't infer specific attributes when as is omitted, so it falls back to a much looser type.
  • Breaking inference through indirection. Wrapping a polymorphic component in another generic function (e.g., a styled() helper) without carefully re-exposing the generic signature will often collapse T back to its default at the outer layer, even though the inner component still resolves correctly in isolation.
  • Ignoring TypeScript compiler performance. Heavily nested generic polymorphic types can noticeably slow down tsserver in large codebases with many design system components. This is a documented trade-off, which is why some teams choose to make only a small set of primitive components (Box, Text, Button) polymorphic rather than every component in the system.

Alternatives Worth Knowing: asChild and renderRoot

The as prop pattern is powerful but not the only approach in production design systems, and it's worth understanding the alternatives so the choice is deliberate rather than default.

asChild (slot-based composition). Instead of passing a tag name or component reference, asChild tells the component to merge its props and behavior onto its single child element, rather than rendering its own wrapper element. This avoids some of the generic-type complexity of the as pattern because there's no need to infer attribute types from a dynamic tag — the child element already carries its own types. It does, however, require careful prop-merging logic (refs, event handlers, class names) at runtime.

component / renderRoot props. Some libraries use a component prop instead of as, with a renderRoot escape hatch for cases where a generic component (like a strongly-typed router Link with a generic to prop) can't be reliably inferred through a prop-based API at all. This acknowledges a real limitation: TypeScript's inference through a prop passed into a generic component has real boundaries, and sometimes a render-prop function is the more honest solution.

Choosing between as, asChild, and renderRoot is less about which is objectively superior and more about which failure mode a team is willing to accept: as trades compiler complexity for API simplicity, while asChild/renderRoot trade a bit of API surface for cleaner type inference in edge cases like generic router components.

Testing Type Safety, Not Just Runtime Behavior

A subtle but important practice for design systems: type-level correctness needs its own tests, separate from runtime unit tests. A polymorphic component can pass every runtime test while its types have quietly regressed to any after a refactor.

Tools like tsd or expect-type (used alongside a standard test runner) let a team assert things like:

tsx

expectTypeOf<typeof Button<"a">>().not.toBeAny();
// @ts-expect-error - href is not valid on a button element
<Button href="/somewhere" />;

Committing these type-level checks to CI catches the exact class of regression that's easiest to introduce and hardest to notice: a generic collapsing silently, without any runtime error, anywhere.

Putting It Together in a Design System

In practice, most design systems apply this pattern to a small number of foundational, highly-reused primitives — typically Box, Text, Heading, and interactive elements like Button and Link — rather than to every component. Higher-level composite components (a Card, a Modal) then compose these polymorphic primitives internally, inheriting flexibility without needing their own generic machinery.

This layered approach keeps the complexity contained: a handful of well-tested polymorphic primitives absorb the hard type-theory work once, and the rest of the system benefits from correct semantics and flexible rendering without every component author needing to understand ComponentPropsWithRef inference rules.

Conclusion

Polymorphic components solve a real, recurring problem in design systems: how to keep a single, well-tested component while still allowing it to render as whatever HTML element or custom component a given context requires — without sacrificing type safety or falling back on brittle boolean props. The as prop pattern, built on ElementType generics and careful prop extraction with ComponentPropsWithoutRef/ComponentPropsWithRef, is the mechanism that makes this possible in TypeScript, and it's why it underpins so many production design systems today.

The pattern has real costs — generic complexity, occasional compiler slowdown, and edge cases where inference simply can't reach far enough (which is where asChild or renderRoot step in). But for the small set of foundational primitives most design systems build around, it delivers exactly what's needed: one component, many valid shapes, and a type checker that actually enforces the difference.

Further Reading (Official Documentation)