Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Типизация полиморфных компонентов в React через дженерики: полное руководство по пропу as

Тимофей Ищенко

Коротко: Руководство по созданию типобезопасных полиморфных компонентов в 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>;

У такого решения два критических недостатка:

  1. Отсутствие строгой взаимосвязи: TypeScript разрешит передать href элементу с as="button" или атрибут disabled элементу с as="a", хотя для тега <a> атрибут disabled не является валидным стандартом HTML.
  2. Невозможность расширения: Передать кастомный компонент (например, 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-атрибутов

Чтобы компонент оставался гибким, его тип должен объединять:

  1. Собственные свойства (Own Props) — параметры визуального стиля и внутренней логики (variant, size, isLoading).
  2. Атрибуты целевого элемента — специфичные пропсы для переданного тега (например, 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 гибким и семантически корректным без ущерба для безопасности типов.

Чек-лист создания полиморфного компонента:

  1. Ограничить generic-параметр: E extends React.ElementType.
  2. Указать дефолтный элемент (E extends React.ElementType = 'button').
  3. Исключить коллизии нативных и кастомных пропсов через Omit<..., keyof OwnProps | 'as'>.
  4. Привести сигнатуру к generic-интерфейсу при использовании React.forwardRef.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также