Коротко: Руководство по созданию типобезопасных полиморфных компонентов в React и TypeScript с пропом as, поддержкой forwardRef и дженериков.
При разработке дизайн-систем и библиотек UI-компонентов регулярно возникает задача изменить базовый HTML-тег элемента без потери его визуальных стилей и бизнес-логики. Классический пример — кнопка, которая в зависимости от контекста должна рендериться как нативный тег <button>, ссылка <a> или компонент <Link> из используемого роутера (Next.js, React Router).
Реализация пропа as кажется элементарной в JavaScript, однако в TypeScript она требует аккуратной работы с обобщениями (Generics), служебными типами и контекстом React. Неправильный подход приводит к потере автодополнения (IntelliSense), маскировке невалидных атрибутов или ошибкам компиляции при передаче ref.
Что такое полиморфный компонент и зачем он нужен
Полиморфный компонент — это компонент, целевой HTML-тег или базовый React-компонент которого определяется динамически через специальный проп (традиционно называемый as).
Проблема: когда кнопка должна стать ссылкой
С точки зрения доступности (a11y) и семантики интерактивные элементы обязаны строго соответствовать своему поведению:
- Нажатие запускает действие на клиенте — используется тег
<button>. - Нажатие переводит пользователя по новому URL — используется тег
<a>.
Если стилизовать обычную ссылку под кнопку путем копирования CSS-классов, кодовая база быстро обрастает техническим долгом. Создавать отдельные компоненты Button, LinkButton, IconButtonAsLink неэффективно: визуальное оформление, размеры и цветовые палитры у них абсолютно идентичны.
Почему наивный union-тип не работает
Попытка объединить интерфейсы вручную приводит к потере типобезопасности:
type BadButtonProps = {
as?: 'button' | 'a';
variant?: 'primary' | 'secondary';
href?: string;
disabled?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement> & React.AnchorHTMLAttributes<HTMLAnchorElement>;
У такого решения два критических недостатка:
- Отсутствие строгой взаимосвязи: TypeScript разрешит передать
hrefэлементу сas="button"или атрибутdisabledэлементу сas="a", хотя для тега<a>атрибутdisabledне является валидным стандартом HTML. - Невозможность расширения: Передать кастомный компонент (например,
Linkизnext/link) в пропasбез ошибок компиляции не удастся.
Анатомия базовой типизации: дженерик T extends React.ElementType
Основа полиморфизма в TypeScript — generic-параметр, ограниченный типом React.ElementType.
import React from 'react';
type OwnProps<E extends React.ElementType> = {
as?: E;
children?: React.ReactNode;
};
Ограничение типа через React.ElementType
В @types/react тип React.ElementType описывает любую сущность, которую React может отрисовать:
- Строковые литералы стандартных HTML-тегов (
'div','span','button','a'). - Функциональные и классовые компоненты (
React.ComponentType<P>).
Ограничение E extends React.ElementType гарантирует, что в качестве значения as передадут валидный компонент или тег, а не произвольный объект или примитив.
Разделение собственных пропсов и HTML-атрибутов
Чтобы компонент оставался гибким, его тип должен объединять:
- Собственные свойства (Own Props) — параметры визуального стиля и внутренней логики (
variant,size,isLoading). - Атрибуты целевого элемента — специфичные пропсы для переданного тега (например,
hrefиtargetдля<a>,typeиdisabledдля<button>).
Пошаговая сборка универсального типа PolymorphicProps
Соберем типобезопасный контракт поэтапно, устраняя возможные конфликты типов.
┌────────────────────────┐
│ React.ElementType E │
└───────────┬────────────┘
│
┌───────────────────────┴───────────────────────┐
▼ ▼
┌──────────────────┐ ┌───────────────────────┐
│ OwnProps<E> │ │ ComponentPropsWithout │
│ - as?: E │ │ Ref<E> │
│ - variant, size │ │ (HTML-атрибуты) │
└───────┬──────────┘ └───────────┬───────────┘
│ │
│ ┌─────────────────────────────────────┘
│ ▼
│ ┌────────────────────────────────┐
│ │ Omit<..., keyof OwnProps<E>> │ (удаление дубликатов)
│ └──────────────┬─────────────────┘
│ │
▼ ▼
┌──────────────────────────────┐
│ PolymorphicComponentProps<E> │ (готовый контракт)
└──────────────────────────────┘
1. Извлечение атрибутов через ComponentPropsWithoutRef
Для получения стандартных атрибутов элемента по его типу используется утилита React.ComponentPropsWithoutRef<E>:
import React from 'react';
type PolymorphicPropsBasic<E extends React.ElementType, P = {}> = P & {
as?: E;
} & React.ComponentPropsWithoutRef<E>;
2. Разрешение конфликтов имен с помощью Omit
Если в собственных пропсах объявлено свойство, имя которого совпадает с нативным HTML-атрибутом (например, color или size), прямое пересечение через & создаст некорректный тип never для конфликтующего поля.
Чтобы исключить дублирование, нативные пропсы фильтруются служебным типом Omit:
export type PolymorphicComponentProps<
E extends React.ElementType,
P = {}
> = {
as?: E;
} & P &
Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'>;
Практическая реализация: компоненты Text и Button
Применим полученную утилиту типов для создания компонентов UI-кита.
Полный код компонента Text
import React from 'react';
// 1. Описываем собственные пропсы
type TextBaseProps = {
variant?: 'body' | 'heading' | 'caption';
weight?: 'regular' | 'medium' | 'bold';
children: React.ReactNode;
};
// 2. Объединяем с дженериком полиморфизма
export type TextProps<E extends React.ElementType = 'span'> =
PolymorphicComponentProps<E, TextBaseProps>;
// 3. Реализуем компонент
export function Text<E extends React.ElementType = 'span'>({
as,
variant = 'body',
weight = 'regular',
className,
children,
...restProps
}: TextProps<E>) {
const Component = as || 'span';
const classes = `text-${variant} text-${weight} ${className || ''}`.trim();
return (
<Component className={classes} {...restProps}>
{children}
</Component>
);
}
Проверка валидации типов в IDE
TypeScript обеспечивает строгий контроль пропсов на этапе написания кода:
// 1. По умолчанию рендерится <span>: валидны стандартные span-атрибуты
<Text>Простой текст</Text>;
// 2. Рендерится <h1>: доступен API заголовков
<Text as="h1" variant="heading">
Заголовок первого уровня
</Text>;
// 3. Рендерится <a>: требуется href, разрешен target
<Text as="a" href="https://codesrc.ru" target="_blank">
Внешняя ссылка
</Text>;
// 4. ОШИБКА КОМПИЛЯЦИИ: у тега <p> нет атрибута href
// Property 'href' does not exist on type ...
<Text as="p" href="/about">
Невалидный параграф
</Text>;
Продвинутый уровень: типизация ref и полиморфизм
Передача ссылок (ref) в полиморфные компоненты сопряжена с особенностью React.forwardRef: эта функция в @types/react не умеет напрямую пробрасывать внешний дженерик на возвращаемый компонент.
Корректное приведение сигнатуры для forwardRef
Чтобы поддержать передачу ref, создается вспомогательный тип PolymorphicRef и выполняется явное приведение generic-сигнатуры:
import React from 'react';
// Извлечение правильного типа ref для переданного элемента
export type PolymorphicRef<E extends React.ElementType> =
React.ComponentPropsWithRef<E>['ref'];
// Пропсы с поддержкой типизированного ref
export type PolymorphicComponentPropsWithRef<
E extends React.ElementType,
P = {}
> = PolymorphicComponentProps<E, P> & {
ref?: PolymorphicRef<E>;
};
// Сигнатура полиморфного компонента с ref
type PolymorphicForwardRefComponent = <E extends React.ElementType = 'button'>(
props: PolymorphicComponentPropsWithRef<E, { variant?: 'primary' | 'secondary' }>
) => React.ReactNode;
// Внутренняя реализация
const ButtonRenderFunction = (
props: PolymorphicComponentPropsWithRef<React.ElementType>,
ref: React.ForwardedRef<unknown>
) => {
const { as: Component = 'button', variant = 'primary', className, ...rest } = props;
const classes = `btn btn-${variant} ${className || ''}`.trim();
return <Component ref={ref} className={classes} {...rest} />;
};
// Экспорт с переопределенной generic-сигнатурой
export const Button: PolymorphicForwardRefComponent =
React.forwardRef(ButtonRenderFunction) as unknown as PolymorphicForwardRefComponent;
Использование компонента с корректными ссылками:
function NavigationBar() {
const buttonRef = React.useRef<HTMLButtonElement>(null);
const anchorRef = React.useRef<HTMLAnchorElement>(null);
return (
<nav>
{/* ref типизирован как HTMLButtonElement */}
<Button ref={buttonRef} variant="primary">
Кнопка действия
</Button>
{/* ref типизирован как HTMLAnchorElement */}
<Button as="a" href="/dashboard" ref={anchorRef}>
Переход в дашборд
</Button>
</nav>
);
}
Архитектурные альтернативы: проп as или паттерн asChild
В современной экосистеме UI-библиотек (например, Radix UI) популярность приобрел паттерн композиции через свойство asChild (концепция слотов).
| Критерий | Паттерн as |
Паттерн asChild (Slot) |
|---|---|---|
| Механизм | Рендерит переданный тег/компонент напрямую | Клонирует переданного потомка и объединяет его пропсы |
| Сложность типов | Требует дженериков, Omit и каста forwardRef |
Простая типизация: компонент принимает только свои пропсы |
| Формат вызова | <Button as="a" href="/">Перейти</Button> |
<Button asChild><a href="/">Перейти</a></Button> |
| Проброс ref | Требует явной настройки PolymorphicRef |
Выполняется прозрачно через переданного потомка |
Когда что выбирать:
- Проп
asидеально подходит для базовых элементов верстки и типографики (Text,Heading,Box,Container). - Паттерн
asChildудобен для сложных интерактивных компонентов с глубокой структурой (модальные окна, дропдауны, триггеры тултипов), где важно явно видеть рендер ссылки или кнопки в JSX.
Часто задаваемые вопросы (FAQ)
Чем отличается React.ElementType от React.ReactNode и React.ComponentType?
React.ElementType описывает типы, которые можно передать в качестве тега при создании элемента (строки 'div', 'button' или пользовательские компоненты). React.ComponentType включает исключительно функциональные и классовые компоненты. React.ReactNode — это результат рендеринга (готовый JSX, строка, число, фрагмент или null), который нельзя передать в JSX как имя тега.
Почему TypeScript теряет дженерики при оборачивании компонента в React.forwardRef?
Сигнатура React.forwardRef в стандартных типах React не рассчитана на сохранение внешних generic-параметров функции. При передаче generic-функции внутрь forwardRef компилятор фиксирует пропсы по значениям по умолчанию. Чтобы вернуть дженерик наружу, применяется утверждение типов (type assertion) к итоговой константе.
В чем разница между ComponentPropsWithoutRef<T> и ComponentProps<T>?
React.ComponentProps<T> извлекает полный набор свойств элемента, включая свойство ref. React.ComponentPropsWithoutRef<T> исключает ref. Это разделение необходимо, чтобы избежать конфликтов типов при самостоятельной обработке ссылок через forwardRef.
Можно ли передавать кастомный React-компонент в проп as?
Да. Поскольку дженерик ограничен React.ElementType, в проп as можно передать любой сторонний компонент (например, Link из Next.js). TypeScript автоматически подтянет все обязательные пропсы этого компонента в автодополнение.
Заключение
Типизация полиморфных компонентов через дженерики делает UI-kit гибким и семантически корректным без ущерба для безопасности типов.
Чек-лист создания полиморфного компонента:
- Ограничить generic-параметр:
E extends React.ElementType. - Указать дефолтный элемент (
E extends React.ElementType = 'button'). - Исключить коллизии нативных и кастомных пропсов через
Omit<..., keyof OwnProps | 'as'>. - Привести сигнатуру к generic-интерфейсу при использовании
React.forwardRef.




.svg.webp)





