У проєктуванні масштабованих дизайн-систем візуальний вигляд компонента не повинен бути жорстко прив'язаний до конкретного 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.