En el diseño de sistemas de diseño escalables, la apariencia visual de un componente nunca debe estar rígidamente vinculada a una etiqueta HTML concreta. Por ejemplo, un elemento interactivo <Button> puede lucir visualmente como un botón de acción destacado; sin embargo, según la semántica de la página y las pautas de accesibilidad (a11y), debe ser capaz de renderizarse como un <button> nativo, un hiperenlace <a href="..."> o un enlace de enrutador <Link href="...">.
El polimorfismo moderno en React 19 aborda la sustitución dinámica de etiquetas o componentes mediante la prop as, conservando el autocompletado estricto y la validación de atributos HTML:
Sin polimorfismo, los equipos de desarrollo se ven forzados a tomar uno de tres caminos perjudiciales:
<Button>, <LinkButton>, <ExternalLinkButton> y <DivButton>. Esto desencadena una duplicación masiva de código, divergencia en el diseño y redundancia en la lógica de estados (hover, focus, disabled, loading).as?: any): Uso de any o uniones de props no discriminadas. En tales casos, TypeScript consiente pasar un href a un <button> nativo (donde el navegador lo ignora) o type="submit" a un tag <a>, originando fallos sutiles de maquetación y accesibilidad.<button><a href="...">...</a></button>), lo que representa una grave transgresión de la especificación HTML5 (elementos interactivos anidados) y bloquea la navegación en lectores de pantalla (screen readers).// ❌ ANTIPATRÓN: Falta de tipado en el polimorfismo
interface BadButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
as?: any;
href?: string; // ¡Permitido en cualquier llamada, incluso cuando se renderiza <button>!
}
// ✅ OBJETIVO DEL POLIMORFISMO ESTRICTO:
<Button as="button" type="submit">Enviar</Button>; // Válido
<Button as="a" href="/dashboard">Al panel</Button>; // Válido: href está permitido
// @ts-expect-error — Property 'href' does not exist on type Button when as="button"
<Button as="button" href="/dashboard">Error</Button>;En TypeScript, el polimorfismo se sustenta en la inferencia discriminante de tipos mediante genéricos (Generics). En lugar de fusionar estáticamente cientos de interfaces HTML, definimos el componente como una función genérica parametrizada por el elemento de destino E extends React.ElementType.
El compilador de TypeScript resuelve las props de un componente polimórfico en tres etapas secuenciales:
E a partir de la prop as suministrada (o recurre al valor por defecto 'button').E mediante React.ComponentPropsWithoutRef<E>.Omit, combinando los atributos nativos depurados con las props personalizadas del componente.Definición de la interfaz propia del componente (variant, size, isLoading, color) independiente de atributos HTML.
Declaración del componente con el parámetro <E extends React.ElementType = 'button'> para capturar el tipo de etiqueta dinámicamente.
Exclusión de las claves coincidentes entre las props personalizadas, la prop 'as' y React.ComponentPropsWithoutRef<E>.
Vinculación de React.ComponentPropsWithRef<E>['ref'] y definición de valores defensivos (type='button').
¿Con qué propósito se utiliza necesariamente el utilitario Omit<React.ComponentPropsWithoutRef<E>, PropsToOmit<E, Props>> en el tipo PolymorphicComponentProps en lugar de una intersección directa (&)?
Componentes como <Typography as="h1" variant="display"> o <Typography as="p" variant="body">, donde el estilo visual está desvinculado de la jerarquía semántica en el DOM. Esto garantiza una adecuada estructura de encabezados para SEO y accesibilidad sin importar el tamaño de la fuente.
Escribir <MenuItem as={Link} href="/settings">, eliminando duplicidades de estilos y marcado entre botones de acción estándar y enlaces de navegación del cliente. TypeScript exigirá obligatoriamente la prop href correspondiente a Link.
Convertir una tarjeta <Card as="article"> en un elemento navegable sin producir HTML inválido. La especificación HTML5 prohíbe anidar elementos interactivos de bloque dentro de un tag <a>, por lo que el polimorfismo otorga el control exacto del nodo contenedor.
La resolución de tipos polimórficos por el compilador de TypeScript sigue un cauce determinista:
Determinación del valor del parámetro genérico E (por defecto 'button' o el tag/componente recibido en la prop as).
Cálculo de las claves pertenecientes a Props y a la prop 'as' para suprimirlas de los atributos nativos de E.
Unión de los atributos HTML nativos saneados con las props personalizadas (variant, size, isLoading).
Resolución del tipo exacto de ref (HTMLButtonElement frente a HTMLAnchorElement) sin recurrir a conversiones con any.
En el ecosistema contemporáneo de React coexisten dos enfoques principales para gestionar la semántica dinámica: el polimorfismo tradicional mediante la prop as y el patrón de composición Slot (asChild), popularizado por Radix UI.
¿Qué ocurre si en un Button polimórfico con as='a' el desarrollador intenta pasar una referencia ref de tipo React.RefObject<HTMLButtonElement>?
A continuación se define un módulo de utilidades de tipado polimórfico utilizable en cualquier componente:
import * as React from 'react';
/**
* Prop `as` que determina la etiqueta HTML o componente de React a renderizar
*/
export type AsProp<E extends React.ElementType> = {
as?: E;
};
/**
* Claves que deben suprimirse de los atributos nativos del elemento E
* para evitar colisiones accidentales con props personalizadas.
*/
export type PropsToOmit<E extends React.ElementType, P> = keyof (AsProp<E> & P);
/**
* Tipo base para componentes polimórficos sin ref explícito
*/
export type PolymorphicComponentProps<
E extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<E>> &
Omit<React.ComponentPropsWithoutRef<E>, PropsToOmit<E, Props>>;
/**
* Cálculo dinámico del tipo de referencia (Ref) para el elemento E recibido
*/
export type PolymorphicRef<E extends React.ElementType> =
React.ComponentPropsWithRef<E>['ref'];
/**
* Tipo integral de props polimórficas incluyendo el ref debidamente tipado
*/
export type PolymorphicComponentPropsWithRef<
E extends React.ElementType,
Props = {}
> = PolymorphicComponentProps<E, Props> & {
ref?: PolymorphicRef<E>;
};En React 19, ref es una prop estándar del componente funcional y no requiere el envoltorio legado React.forwardRef. Esto permite estructurar funciones genéricas sumamente limpias:
import * as React from 'react';
import {
PolymorphicComponentPropsWithRef,
PolymorphicRef,
} from '@/types/polymorphic';
export interface ButtonCustomProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const variantStyles: Record<NonNullable<ButtonCustomProps['variant']>, string> = {
primary: 'bg-accent-violet text-white hover:bg-accent-violet/90',
secondary: 'bg-bg-surface text-text-primary border border-border hover:bg-bg-surface/80',
ghost: 'bg-transparent text-text-secondary hover:text-text-primary',
destructive: 'bg-red-600 text-white hover:bg-red-700',
};
const sizeStyles: Record<NonNullable<ButtonCustomProps['size']>, string> = {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
};
/**
* Botón polimórfico idiomático en React 19
*/
export function Button<E extends React.ElementType = 'button'>({
as,
variant = 'primary',
size = 'md',
isLoading = false,
className = '',
children,
ref,
disabled,
...restProps
}: PolymorphicComponentPropsWithRef<E, ButtonCustomProps>) {
const Component = as || 'button';
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-violet disabled:opacity-50 disabled:pointer-events-none cursor-pointer select-none';
const combinedClassName = [
baseStyles,
variantStyles[variant],
sizeStyles[size],
className,
]
.filter(Boolean)
.join(' ');
// Si se renderiza un botón nativo, establecemos type="button" defensivamente
// para evitar el envío involuntario de formularios padre
const defaultProps = Component === 'button' ? { type: 'button' as const } : {};
return (
<Component
ref={ref as React.Ref<Element>}
className={combinedClassName}
disabled={isLoading || disabled}
aria-busy={isLoading || undefined}
{...defaultProps}
{...restProps}
>
{isLoading ? (
<span className="inline-flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{children}
</span>
) : (
children
)}
</Component>
);
}La tipografía es el caso representativo de polimorfismo en sistemas de diseño: salvaguarda la concordancia con los design tokens (color, grosor, interlineado) ofreciendo total libertad para elegir la etiqueta semántica HTML (h1–h6, p, span, label, code).
import * as React from 'react';
import { PolymorphicComponentPropsWithRef } from '@/types/polymorphic';
export type TypographyVariant =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'body-lg'
| 'body-md'
| 'body-sm'
| 'caption'
| 'code';
export interface TypographyCustomProps {
variant?: TypographyVariant;
color?: 'primary' | 'secondary' | 'muted' | 'accent';
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
}
const variantStyles: Record<TypographyVariant, string> = {
h1: 'text-4xl sm:text-5xl font-extrabold tracking-tight',
h2: 'text-3xl sm:text-4xl font-bold tracking-tight',
h3: 'text-2xl sm:text-3xl font-bold',
h4: 'text-xl sm:text-2xl font-semibold',
'body-lg': 'text-lg leading-relaxed',
'body-md': 'text-base leading-normal',
'body-sm': 'text-sm leading-normal',
caption: 'text-xs tracking-wide uppercase',
code: 'font-mono text-sm bg-bg-surface/60 px-1.5 py-0.5 rounded border border-border',
};
const colorStyles: Record<NonNullable<TypographyCustomProps['color']>, string> = {
primary: 'text-text-primary',
secondary: 'text-text-secondary',
muted: 'text-text-muted',
accent: 'text-accent-violet',
};
const weightStyles: Record<NonNullable<TypographyCustomProps['weight']>, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
// Mapeo semántico de etiquetas HTML predeterminadas para cada variante visual
const defaultElementMap: Record<TypographyVariant, React.ElementType> = {
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
'body-lg': 'p',
'body-md': 'p',
'body-sm': 'p',
caption: 'span',
code: 'code',
};
export function Typography<E extends React.ElementType = 'p'>({
as,
variant = 'body-md',
color = 'primary',
weight,
className = '',
children,
ref,
...restProps
}: PolymorphicComponentPropsWithRef<E, TypographyCustomProps>) {
// Se toma 'as' explícito, el tag predeterminado de la variante o 'p' por defecto
const Component = as || defaultElementMap[variant] || 'p';
const classes = [
variantStyles[variant],
colorStyles[color],
weight && weightStyles[weight],
className,
]
.filter(Boolean)
.join(' ');
return (
<Component
ref={ref as React.Ref<Element>}
className={classes}
{...restProps}
>
{children}
</Component>
);
}Intentar reducir la verbosidad con as?: any destruye la comprobación estática de TypeScript. Los desarrolladores podrán pasar href a un botón o disabled a un enlace sin que el compilador emita advertencias, desencadenando fallos en ejecución y anomalías de accesibilidad.
// ❌ ANTIPATRÓN: Ausencia de tipado entre la etiqueta y sus atributos
interface BadButtonProps extends React.HTMLAttributes<HTMLElement> {
as?: any;
}
// ¡El compilador tolera esta llamada a pesar de que <button> carece de href!
<BadButton as="button" href="/dashboard" />
// ✅ RECOMENDADO: Genérico estricto PolymorphicComponentProps
<Button as="button" href="/dashboard" /> // Error de TS: Property 'href' does not exist on type '...'Cuando un componente declara una prop personalizada cuyo nombre coincide con un atributo nativo de HTML (por ejemplo, color como unión de temas frente al atributo HTML obsoleto color en ciertas etiquetas), la intersección directa Props & React.ComponentPropsWithoutRef<E> resulta en never o en un choque de tipos.
// ❌ ANTIPATRÓN: Intersección directa sin Omit
type BrokenProps<E extends React.ElementType> = {
color?: 'primary' | 'secondary'; // Unión de strings
} & React.ComponentPropsWithoutRef<E>; // ¡En ciertos tags nativos color es string!
// Resultado: Conflicto entre 'primary' | 'secondary' y string
// ✅ RECOMENDADO: Exclusión del atributo nativo con Omit
type CorrectProps<E extends React.ElementType> = {
color?: 'primary' | 'secondary';
} & Omit<React.ComponentPropsWithoutRef<E>, 'color'>;Forzar ref a React.Ref<HTMLButtonElement> dentro de un componente polimórfico generará problemas encubiertos si el consumidor pasa as='a' junto a una referencia de tipo HTMLAnchorElement.
// ❌ ANTIPATRÓN: Ref fijado exclusivamente a elemento botón
export function BadComponent<E extends React.ElementType = 'div'>({
ref,
}: {
ref?: React.Ref<HTMLDivElement>; // ¡Conflicto de tipos si se envía as="button"!
}) { /* ... */ }
// ✅ RECOMENDADO: Cálculo dinámico mediante PolymorphicRef<E>
ref?: PolymorphicRef<E>Bajo el estándar HTML5, un elemento <button> sin el atributo type explícito asume por defecto type="submit". Si un botón polimórfico se renderiza dentro de un formulario sin definir type, cualquier pulsación enviará el formulario de manera accidental. Asigne defensivamente type="button" por defecto cuando Component === 'button'.
Sustituir una etiqueta semántica <button> por un <div> mediante as="div" elimina el enfoque por teclado, los roles en el árbol de accesibilidad y los eventos Enter/Space. Si resulta inevitable utilizar tags no semánticos, el desarrollador debe suministrar manualmente role="button", tabIndex={0}, listeners de teclado en onKeyDown y el atributo aria-disabled.