Всем привет! Сегодня я возвращаюсь с новой порцией TypeScript- и React-магии. Вместе разберем полиморфизм в React, а именно — паттерн as. Зачем он нужен, как его прикрутить без багов и почему это сделает ваши компоненты в разы круче. Как обычно — всё под катом.
В каких случаях это нужно
Представьте, что вы сделали красивую кнопку Button. Всё летает, дизайнеры плачут от счастья. И тут приходит продукт-менеджер: «А сделай так, чтобы вот эта кнопка стала ссылкой на главную».
«Не вопрос!» — говорите вы и лепите костыль:
function Button({ href, children, ...props }) {
const Tag = href ? 'a' : 'button';
return <Tag href={href} {...props}>{children}</Tag>;
}
Работает? Работает. Пока не попросят сделать ее Link из react-router. Или SnackButton. Или что-нибудь еще в таком же духе.
Добавлять условия на каждый случай — путь в ад. Тут-то нас и выручает паттерн as.
Паттерн as: дистрибуция на уровне компонентов
Если в типах мы раскладываем юнионы через extends, то в React мы можем заставить один компонент рендериться как другой. По сути, это та же дистрибуция, но на уровне JSX.
Наивная попытка (сразу в продакшн!):
function Polymorph({ as: Tag }) {
return <Tag />;
}
// Использование
<Polymorph as={Link} /> // Рендерит <Link />
<Polymorph as={SnackButton} /> // Рендерит <SnackButton />
Но TypeScript начнет ругаться, как только мы попробуем добавить пропсы для нашего полиморфа: «А какие пропсы у Link? А у SnackButton?» . И будет прав. Наша наивная реализация не принимает пропсы самого компонента Tag. Да и с обычными HTML-тегами не работает.
Боевая версия с TypeScript
Ключ к успеху —ElementType и правильная обработка пропсов:
import { createElement, ElementType, ComponentPropsWithoutRef } from 'react';
type PropsOf<T extends ElementType> = ComponentPropsWithoutRef<T>;
function Polymorph<TComponent extends ElementType>({
as: Tag,
...props
}: {
as: TComponent;
} & PropsOf<TComponent>) {
return <Tag {...props} />;
}
Что здесь происходит?
TComponent extends ElementType— наш дженерик, ограниченный типом «любой React-компонент или тег».PropsOf<TComponent>— вытаскиваем пропсы этого компонента/тега.<Tag {...props} />— создаем компонентTagи прокидываем в негоprops.
Теперь наш компонент по-настоящему универсален...
// Рендерит <Link href="#" />
<Polymorph as={Link} href="#" />
// Рендерит <SnackButton type="primary" />
<Polymorph as={SnackButton} type="primary" />
// Рендерит <a autoFocus />
<Polymorph as="a" autoFocus />
... и совершенно бесполезен! Если задуматься, он не делает совершенно ничего, а только отрисовывает переданный компонент и добавляет в него его пропсы. НоPolymorph для этого совершенно не нужен. Давайте посмотрим на реальный пример использования такого компонента «в бою».
as для дизайн-систем: сохраняем дизайн, меняем семантику
Вот где паттерн as раскрывается на полную. Представьте, что у вас в дизайн-системе есть Typography с кучей вариантов:
// Было в дизайн-системе
// Всегда рендерит h1
<Typography variant="h1">Заголовок</Typography>
// Всегда рендерит p
<Typography variant="body">Текст</Typography>
А потом приходит SEO-специалист и говорит: «Ребята, у вас на странице три <h1>, это плохо». Раньше пришлось бы городить костыли или плодить компоненты. А теперь всё просто:
// Стало с полиморфизмом
// Такой же h1, как и был
<Typography as="h1" variant="h1">Главный заголовок</Typography>
// Выглядит как h1, но семантика h2!
<Typography as="h2" variant="h1">Второстепенный заголовок</Typography>
// Бонусы для доступности - иногда параграф не нужен
<Typography as="span" variant="body">Текст в span</Typography>
Что мы получаем (спойлер: все довольны!):
Дизайнеры довольны: все размеры, отступы и цвета остаются как в макете.
Разработчики довольны: семантика на месте, SEO-специалист не ругается.
TypeScript доволен: не дает передавать
hrefвTypographyилиcolspanвbutton.
Тот же подход будет работать для любых компонентов дизайн-системы:
// Кнопка, которая выглядит как кнопка, но ведет себя как ссылка
<Button as={Link} to="/about" variant="primary">
О нас
</Button>
// Карточка, которая является ссылкой
<Card as={Link} href="/product/1" className="product-card">
{content}
</Card>
Конфликт пропсов
Иногда случается, что пропсы нашего полиморфного компонента и целевого тега вступают в конфликт. Представьте, что у вашей универсальной кнопки есть пропс color, и вы хотите отрендерить ее как Link из react-router, у которого тоже есть пропс color — но с совершенно другим типом и назначением.
TypeScript моментально загорится красным: «А какой именно color вы имеете в виду?». И будет прав. Наша боевая версия с React.ComponentPropsWithoutRef не знает, как поступать в такой неоднозначной ситуации.
enum Color {...}
/* Полиморфная кнопка со своим пропсом `color` */
type PolymorphButtonProps = {
color: Color;
}
/* При попытке рендерить как `Link` */
<PolymorphButton as={Link} to="/home" color="primary">
На главную
</PolymorphButton>
/* Тип "primary" не может быть назначен типу Color. */
Как с этим жить?
-
Переименовывайте. Просто не называйте пропсы одинаково. Вместо
colorиспользуйтеvariantилиthemeColorдля пропсов дизайн-системы:interface PolymorphButtonProps { variant: 'primary' | 'secondary'; // Конфликт устранен! } -
Ограничивайте пропсы. В дженериках можно явно исключить конфликтующие пропсы из типа полиморфного компонента, используя утилиту
Omit:type PolymorphProps<T extends ElementType> = { as?: T; // Все остальные пропсы, КРОМЕ тех, что уже есть у нас } & Omit<React.ComponentPropsWithoutRef<T>, keyof PolymorphButtonProps>; -
Внедряйте скоупы. Пропсы полиморфного компонента можно занести в одну переменную, тогда пересечение станет невозможно:
type PolymorphProps<T extends ElementType> = { as?: T; polymorhOnlyProps: PolymorphOnlyProps; } & React.ComponentPropsWithoutRef<T>; /* Теперь наш пример корректо сработает */ <PolymorphButton polymorhOnlyProps={{color: 'primary'}} as={Link} to="/home"> На главную </PolymorphButton>
Выводы про паттерн as
Это не серебряная пуля, но нередко выручает. Резюмирую:
+ Универсальность: работает с любым компонентом или тегом.
+ Полная типизация «родных» пропсов: TypeScript не даст вам запутаться.
+ Идеален для дизайн-систем: помогает отделить внешний вид от семантики.
- Плохо композируется: при вложении компонентов возникает конфликт пропсов.
- Слабо читаемый: запись
as={Link}выглядит неочевидно.
Особенно эффективно as выручает в дизайн-системах, где нужно сохранять единый визуал, но гибко управлять семантикой. Начните применять его в базовых компонентах (типа Box, Button, Text), и наверняка ваша кодовая база станет гибче и типобезопаснее.
Ограничения паттерна as можно обойти, используя другой, менее известный паттерн asChild, читайте о нем в следующей статье ?
Кстати, все примеры из статьи из нашего реального продакшна. Есть чем дополнить? Жду в комментариях!
Комментарии (2)

oookkdjjjdjdj
07.11.2025 14:34Удобный паттерн, если делаешь дизайн-систему. Но вне неё выглядит избыточно
DmitryOlkhovoi
С CVA сравнивали?