В проектировании масштабируемых дизайн-систем визуальный облик компонента не должен быть жестко привязан к конкретному HTML-тегу. Например, интерактивный элемент <Button> визуально может выглядеть как акцентная кнопка действия, однако в зависимости от семантики страницы и требований доступности (a11y) он обязан рендериться как нативный <button>, гиперссылка <a href="..."> или компонент роутера <Link href="...">.
Современный полиморфизм в React 19 решает задачу динамической смены рендеримого тега или компонента через проп as с сохранением строгого автодополнения и валидации HTML-атрибутов:
Без полиморфизма разработчики вынуждены идти по одному из трех путей, каждый из которых создает серьезные архитектурные проблемы:
<Button>, <LinkButton>, <ExternalLinkButton>, <DivButton>. Это приводит к разрастанию кодовой базы, расхождению стилей и дублированию логики состояний (hover, focus, disabled, loading).as?: any): использование any или недискриминированных объединений пропсов. В этом случае TypeScript разрешает передачу href в нативную кнопку <button> (где он игнорируется браузером) или type="submit" в тег <a>, порождая трудноуловимые баги верстки и доступности.<button><a href="...">...</a></button>), что является грубейшим нарушением стандарта HTML5 (вложенные интерактивные элементы) и блокирует работу экранных дикторов (screen readers).// ❌ АНТИПАТТЕРН: Отсутствие типизации полиморфизма
interface BadButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
as?: any;
href?: string; // Разрешено для любого вызова, даже когда рендерится <button>!
}
// ✅ ЦЕЛЬ СТРОГОГО ПОЛИМОРФИЗМА:
<Button as="button" type="submit">Отправить</Button>; // Корректно
<Button as="a" href="/dashboard">В панель</Button>; // Корректно: href разрешен
// @ts-expect-error — Property 'href' does not exist on type Button when as="button"
<Button as="button" href="/dashboard">Ошибка</Button>;В TypeScript полиморфизм строится на дискриминантном выводе типов через обобщения (Generics). Вместо статического объединения сотен HTML-интерфейсов мы объявляем компонент как обобщенную функцию от целевого элемента E extends React.ElementType.
Компилятор TypeScript разрешает пропсы полиморфного компонента в три последовательных этапа:
E из переданного пропа as (или берет дефолтный 'button').E через React.ComponentPropsWithoutRef<E>.Omit, объединяя очищенные нативные атрибуты с кастомными пропсами компонента.Определение внешнего интерфейса компонента (variant, size, isLoading, color) без привязки к HTML-атрибутам.
Объявление компонента с параметром <E extends React.ElementType = 'button'> для динамического захвата типа тега.
Исключение совпадающих ключей кастомных пропсов и пропа 'as' из React.ComponentPropsWithoutRef<E>.
Привязка React.ComponentPropsWithRef<E>['ref'] и установка защищенных дефолтных значений (type='button').
С какой целью в типе PolymorphicComponentProps обязательно используется утилита Omit<React.ComponentPropsWithoutRef<E>, PropsToOmit<E, Props>> вместо простого пересечения типов (&)?
Компонент <Typography as="h1" variant="display"> или <Typography as="p" variant="body">, где типографические стили отделены от семантического уровня заголовков в DOM. Это позволяет выдерживать правильную иерархию заголовков для SEO и a11y независимо от размера шрифта.
Компонент <MenuItem as={Link} href="/settings">, исключающий дублирование разметки и стилей между обычными кнопками действий и ссылками клиентской маршрутизации. TypeScript автоматически потребует передачу обязательного пропа href.
Превращение карточки <Card as="article"> в ссылку без создания невалидного HTML. Помещение блочных интерактивных элементов внутрь тега <a> строго запрещено спецификацией HTML5, поэтому полиморфизм позволяет тонко управлять корневым узлом контейнера.
Разрешение полиморфных типов компилятором TypeScript происходит по строго выверенной цепочке преобразований типов:
Определение значения generic-параметра E (по умолчанию 'button' или переданный тег/компонент из пропа as).
Вычисление ключей пользовательского интерфейса Props и пропа 'as' для их исключения из нативных атрибутов элемента E.
Объединение очищенных нативных HTML-атрибутов с кастомными пропсами (variant, size, isLoading).
Извлечение точного типа ref (HTMLButtonElement vs HTMLAnchorElement) без небезопасного приведения к any.
В современной экосистеме React существует два доминирующих подхода к динамической семантике: классический полиморфизм через проп as и композиционный паттерн Slot (asChild), популяризированный Radix UI.
Что произойдет, если в полиморфном компоненте Button при as='a' разработчик попытается передать ссылку ref типа React.RefObject<HTMLButtonElement>?
Создадим модуль универсальных типов, применимый для любых компонентов дизайн-системы:
import * as React from 'react';
/**
* Проп `as`, определяющий тип рендеримого тега или React-компонента
*/
export type AsProp<E extends React.ElementType> = {
as?: E;
};
/**
* Ключи, которые необходимо исключить из нативных атрибутов элемента E,
* чтобы предотвратить случайное переопределение кастомных пропсов.
*/
export type PropsToOmit<E extends React.ElementType, P> = keyof (AsProp<E> & P);
/**
* Основной полиморфный тип для компонентов без явного ref
*/
export type PolymorphicComponentProps<
E extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<E>> &
Omit<React.ComponentPropsWithoutRef<E>, PropsToOmit<E, Props>>;
/**
* Динамическое вычисление точного типа ссылки (Ref) для переданного элемента E
*/
export type PolymorphicRef<E extends React.ElementType> =
React.ComponentPropsWithRef<E>['ref'];
/**
* Полный тип пропсов полиморфного компонента, включающий строго типизированный ref
*/
export type PolymorphicComponentPropsWithRef<
E extends React.ElementType,
Props = {}
> = PolymorphicComponentProps<E, Props> & {
ref?: PolymorphicRef<E>;
};В React 19 ссылка ref является стандартным пропом компонента и не требует использования устаревшей функции React.forwardRef. Это позволяет писать чистые и лаконичные обобщенные функции:
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',
};
/**
* Идиоматичная полиморфная кнопка на 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(' ');
// Если рендерится нативная кнопка, проставляем явный type="button"
// во избежание случайной отправки родительской формы
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>
);
}Компонент типографики — эталонный пример применения полиморфизма в дизайн-системах: он гарантирует строгое следование дизайн-токенам (цвета, начертания, интерлиньяж) при абсолютной свободе выбора семантического 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',
};
// Семантические HTML-элементы по умолчанию для каждого визуального варианта
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>) {
// Выбираем переданный тег 'as', либо дефолтный тег для варианта, либо базовый 'p'
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>
);
}Попытка упростить код через as?: any полностью ломает статическую верификацию TypeScript. Разработчик сможет передать href в кнопку или disabled в ссылку, и компилятор пропустит невалидный код, приводя к рантайм-багам и нарушению доступности.
// ❌ АНТИПАТТЕРН: Отсутствие типизации совместимости тега и атрибутов
interface BadButtonProps extends React.HTMLAttributes<HTMLElement> {
as?: any;
}
// Компилятор пропустит этот вызов, хотя <button> в HTML не имеет атрибута href!
<BadButton as="button" href="/dashboard" />
// ✅ ПРАВИЛЬНО: Строгий Generic PolymorphicComponentProps
<Button as="button" href="/dashboard" /> // TS Error: Property 'href' does not exist on type '...'Если кастомный компонент объявляет проп, имя которого совпадает с нативным атрибутом HTML (например, color в кастомных пропсах против устаревшего HTML-атрибута color у некоторых тегов), пересечение типов Props & React.ComponentPropsWithoutRef<E> приведет к типу never или конфликту типов.
// ❌ АНТИПАТТЕРН: Прямое объединение без Omit
type BrokenProps<E extends React.ElementType> = {
color?: 'primary' | 'secondary'; // Строковый союз
} & React.ComponentPropsWithoutRef<E>; // У некоторых элементов color — это string!
// Результат: конфликт типов 'primary' | 'secondary' и string
// ✅ ПРАВИЛЬНО: Исключение нативного пропа через Omit
type CorrectProps<E extends React.ElementType> = {
color?: 'primary' | 'secondary';
} & Omit<React.ComponentPropsWithoutRef<E>, 'color'>;Принудительное приведение ref к React.Ref<HTMLButtonElement> внутри полиморфного компонента приведет к скрытым рантайм-ошибкам, если потребитель передаст as='a' и ссылку на HTMLAnchorElement.
// ❌ АНТИПАТТЕРН: Жесткая фиксация HTML-элемента ссылки
export function BadComponent<E extends React.ElementType = 'div'>({
ref,
}: {
ref?: React.Ref<HTMLDivElement>; // Если передать as="button", возникнет конфликт типов!
}) { /* ... */ }
// ✅ ПРАВИЛЬНО: Динамический расчет типа ссылки через PolymorphicRef<E>
ref?: PolymorphicRef<E>По спецификации HTML5 тег <button> без явного атрибута type по умолчанию имеет значение type="submit". Если полиморфный компонент рендерится как кнопка внутри формы и разработчик не указал type, любой клик по ней вызовет нежелательную отправку формы. Обязательно подставляйте защитное значение type="button" по умолчанию, когда Component === 'button'.
Если семантический тег <button> подменяется на <div> через as="div", элемент теряет фокусируемость с клавиатуры, роль в дереве доступности и обработку клавиш Enter/Space. При вынужденном использовании несемантических тегов разработчик обязан вручную передавать role="button", tabIndex={0}, обработчики клавиатуры onKeyDown и состояние aria-disabled.