Коротко: Руководство по созданию безопасных React-компонентов в TypeScript с помощью Discriminated Unions и типа never. Защитите код от невозможных состояний.
При проектировании интерфейсных библиотек и компонентов дизайн-систем разработчики часто сталкиваются с раздуванием интерфейса пропсов. Компонент кнопки со временем начинает принимать и onClick, и href, и target, и десятки флагов состояний. Если объявить все эти свойства как опциональные (prop?: type), TypeScript позволит вызвать компонент в заведомо некорректной конфигурации — например, передать одновременно ссылку и обработчик клика или потребовать параметры тоста для обычного статического баннера.
Главная цель безопасной типизации компонентов — сделать невозможные состояния невыразимыми на этапе компиляции (Make impossible states unrepresentable). Разберем, как реализовать условные и взаимоисключающие пропсы с помощью дискриминированных объединений (Discriminated Unions) и типа never.
Проблема «невозможных состояний» на примере универсальной кнопки
Антипаттерн «всё опционально»
Типичный пример разрастания компонента — универсальная кнопка-ссылка. В наивной реализации интерфейс выглядит так:
type BadButtonProps = {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
onClick?: () => void;
href?: string;
target?: string;
disabled?: boolean;
};
С точки зрения системы типов такой интерфейс допускает десятки некорректных комбинаций:
- Переданы одновременно
hrefиdisabled(ссылки в HTML не поддерживают нативный атрибутdisabled). - Переданы одновременно
hrefиonClickс неясным приоритетом рендеринга тега (<a>или<button>). - Не передан ни
href, ниonClick, в результате чего рендерится неинтерактивный элемент.
Чем опасна коллизия onClick и href
Когда типы допускают конфликтные свойства, логика разрешения конфликтов переносится в рантайм. В теле компонента появляются каскадные проверки:
export const BadButton = ({ href, onClick, children, ...rest }: BadButtonProps) => {
if (href && onClick) {
console.warn('Button: переданы одновременно href и onClick. href имеет приоритет.');
}
if (href) {
return <a href={href} {...rest}>{children}</a>;
}
return <button onClick={onClick} {...rest}>{children}</button>;
};
Такой подход приводит к трем проблемам:
- Ошибки обнаруживаются только во время выполнения или на этапе регрессионного тестирования.
- Автодополнение в IDE предлагает разработчику несовместимые свойства.
- Растет когнитивная нагрузка и объем защитного кода внутри компонентов.
Дискриминированные объединения (Discriminated Unions) как фундамент
Дискриминированное объединение — это объединение типов (Union), в каждом члене которого присутствует общее поле с литеральным типом (дискриминант).
type Circle = { kind: 'circle'; radius: number };
type Square = { kind: 'square'; sideLength: number };
type Shape = Circle | Square;
Поле kind выступает меткой. При проверке значения этого поля компилятор TypeScript автоматически сужает тип всей структуры.
Литеральный дискриминант в React-пропсах
В React в роли дискриминанта обычно выступают пропсы variant, type, mode или kind:
type PrimaryVariant = {
variant: 'primary';
elevated?: boolean;
};
type GhostVariant = {
variant: 'ghost';
borderless?: boolean;
};
type ButtonVariantProps = PrimaryVariant | GhostVariant;
Механика сужения типов (Type Narrowing)
Когда TypeScript анализирует ветвление по значению дискриминанта, внутри блока кода доступными становятся только те свойства, которые принадлежат конкретной ветке:
function renderButton(props: ButtonVariantProps) {
if (props.variant === 'primary') {
// TypeScript знает: здесь доступен props.elevated
// props.borderless вызовет ошибку компиляции
return props.elevated ? 'Elevated Primary' : 'Primary';
}
// TypeScript знает: здесь props.variant === 'ghost'
return props.borderless ? 'Borderless Ghost' : 'Ghost';
}
Паттерн 1. Зависимые пропсы через явный дискриминант
Рассмотрим компонент уведомлений (Notification), где для режима всплывающего сообщения (toast) обязательны параметры длительности и позиции, а для статического баннера (banner) они запрещены.
Выделение базовых свойств
Общие для всех вариантов свойства выносятся в базовый тип, после чего объединяются с вариативной частью через пересечение (&):
import React from 'react';
type BaseNotificationProps = {
id: string;
title: string;
children: React.ReactNode;
className?: string;
};
type ToastOptions = {
durationMs: number;
position: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left';
onClose: () => void;
};
type BannerNotificationProps = BaseNotificationProps & {
type: 'banner';
isDismissible?: boolean;
};
type ToastNotificationProps = BaseNotificationProps & {
type: 'toast';
toastOptions: ToastOptions;
isDismissible?: never; // Явно запрещаем свойство баннера
};
export type NotificationProps = BannerNotificationProps | ToastNotificationProps;
Реализация компонента
export const Notification: React.FC<NotificationProps> = (props) => {
if (props.type === 'toast') {
// Доступен props.toastOptions
return (
<div className={`toast toast-${props.toastOptions.position}`}>
<h4>{props.title}</h4>
{props.children}
</div>
);
}
// Автоматически сужено до BannerNotificationProps
return (
<div className="banner">
<h4>{props.title}</h4>
{props.children}
{props.isDismissible && <button type="button">Закрыть</button>}
</div>
);
};
Если разработчик попытается вызвать <Notification type="toast" title="Сохранено" /> без передачи toastOptions, компилятор заблокирует сборку и укажет на отсутствующее обязательное поле.
Паттерн 2. Взаимоисключающие пропсы без общего селектора (трюк с never)
Иногда введение явного дискриминанта (type="link") избыточно или ухудшает эргономику API. Например, если разработчик передал href, компонент должен вести себя как ссылка; если передал onClick — как кнопка.
Почему обычного TypeA | TypeB недостаточно
Попробуем написать наивное объединение:
type AsButton = { onClick: () => void };
type AsLink = { href: string };
type ActionProps = AsButton | AsLink;
Если передать литерал объекта с обоими свойствами, проверка лишних свойств (Excess Property Checks) сработает. Однако если объект пропсов собирается динамически или передается через промежуточные переменные, TypeScript допустит присутствие обоих полей, так как структура формально удовлетворяет требованиям объединения.
Использование prop?: never
Чтобы строго запретить наличие свойства в альтернативной ветке объединения, ему присваивают опциональный тип never:
type BaseProps = {
children: React.ReactNode;
className?: string;
};
type ButtonMode = BaseProps & {
onClick: () => void;
href?: never;
target?: never;
};
type LinkMode = BaseProps & {
href: string;
target?: string;
onClick?: never;
};
export type SmartButtonProps = ButtonMode | LinkMode;
Теперь передача несовместимых свойств блокируется анализатором:
// Ошибка компиляции: Type 'string' is not assignable to type 'undefined'
<SmartButton href="/dashboard" onClick={() => console.log('click')}>
Перейти
</SmartButton>
Универсальный утилити-тип Without и XOR
Чтобы не прописывать prop?: never вручную для каждого поля в больших интерфейсах, используется вспомогательный тип исключающего «ИЛИ» (Exclusive OR):
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = (T | U) extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U;
Применение утилиты для взаимоисключающих групп свойств:
type ActionByClick = {
onClick: () => void;
debounceTime?: number;
};
type ActionByRoute = {
href: string;
replace?: boolean;
};
type SafeButtonProps = BaseProps & XOR<ActionByClick, ActionByRoute>;
Типизация полиморфных компонентов: когда Conditional Props недостаточно
Когда компонент должен не просто менять набор атрибутов, а полностью подменять рендеримый HTML-тег или сторонний React-компонент с пробросом всех нативных атрибутов, объединения типов становятся слишком громоздкими. Для таких сценариев применяется паттерн полиморфного свойства as:
import React, { ElementType, ComponentPropsWithRef } from 'react';
type PolymorphicProps<E extends ElementType, P = {}> = P &
ComponentPropsWithRef<E> & {
as?: E;
};
export function Box<E extends ElementType = 'div'>({
as,
...restProps
}: PolymorphicProps<E>) {
const Component = as || 'div';
return <Component {...restProps} />;
}
| Критерий | Conditional Props / Discriminated Unions | Полиморфные компоненты (as) |
|---|---|---|
| Количество состояний | 2–4 фиксированных бизнес-сценария | Неограниченное число HTML-тегов/компонентов |
| Сообщения об ошибках | Точные и локализованные | Сложные типы с глубоким стеком дженериков |
| Основное назначение | Запрет несовместимой бизнес-логики | Низкоуровневые строительные блоки UI (Box, Text) |
Best Practices при проектировании типизированного API
Опасность ранней деструктуризации пропсов
Распространенная ошибка при работе с Discriminated Unions — попытка деструктурировать пропсы непосредственно в параметрах функции:
// ОШИБКА: TypeScript теряет связь между variant и зависимыми полями
export const BadAlert = ({ variant, onClose, autoHideDuration }: AlertProps) => {
// autoHideDuration останется опциональным/неопределенным для TS
};
Правильный подход: деструктурировать объект только после проверки дискриминанта.
export const SafeAlert: React.FC<AlertProps> = (props) => {
if (props.variant === 'temporary') {
const { autoHideDuration, onClose } = props;
return <AutoHideBanner duration={autoHideDuration} onClose={onClose} />;
}
const { isStatic } = props;
return <StaticBanner isStatic={isStatic} />;
};
Читаемость сообщений об ошибках в IDE
Сложные цепочки дженериков и глубокая вложенность XOR могут приводить к трудночитаемым сообщениям об ошибках в редакторе. Чтобы сохранить читаемость:
- Присваивайте понятные имена промежуточным типам (
LinkModeProps,ButtonModeProps). - Не объединяйте в один компонент сущности с противоположной семантикой. Если наборы пропсов не пересекаются, создайте два раздельных компонента (например,
<Button />и<LinkButton />).
Управление техдолгом в UI-Kit
- Всегда экспортируйте промежуточные типы вариантов, чтобы потребители дизайн-системы могли переиспользовать их в сигнатурах своих функций.
- Покрывайте типы type-level тестами (например, с помощью утилит
expect-typeили библиотекиtsd), чтобы случайный рефакторинг не сломал запрет взаимоисключающих пропсов.
Часто задаваемые вопросы (FAQ)
1. Почему TypeScript не выдает ошибку, если передать лишний проп в объединение без never?
TypeScript использует структурную типизацию. Если тип определен как TypeA | TypeB, а переданный объект полностью удовлетворяет полям TypeA, компилятор считает контракт выполненным, даже если в объекте присутствуют посторонние поля, свойственные TypeB. Явное указание prop?: never гарантирует, что присутствие ключа вызовет несовместимость типов.
2. Можно ли использовать булево поле в качестве дискриминанта?
Да. Литералы true и false являются полноправными типами в TypeScript:
type WithBadge = { hasBadge: true; badgeText: string };
type WithoutBadge = { hasBadge?: false; badgeText?: never };
type BadgeProps = WithBadge | WithoutBadge;
3. Влияет ли использование Discriminated Unions на производительность приложения в рантайме?
Нет. Все типы TypeScript, объединения и проверки never полностью стираются на этапе транспиляции в JavaScript. В рантайм попадает только чистый JS-код без накладных расходов.
4. Почему компилятор ругается на свойства после деструктуризации в сигнатуре функции?
Когда аргументы деструктурируются на входе (const Component = ({ variant, ...rest })), компилятор создает независимые локальные переменные. Связь между значением variant и полями внутри rest разрывается. Сужение типов работает только при обращении к свойствам исходного объекта: props.variant.
5. Что выбрать: Conditional Props или разделение на два разных компонента?
Если элементы имеют разный семантический смысл и разное поведение в дереве доступности (a11y), предпочтительнее разделить их на два компонента (например, Tabs и Accordion). Если компонент визуально идентичен и решает одну UX-задачу (например, действие пользователя в виде кнопки или внешней ссылки), объединение через Conditional Props обеспечивает более цельный DX для команды.
Чек-лист проектирования безопасных пропсов
- В компоненте отсутствуют комбинации, где все свойства опциональны, но логически требуют присутствия друг друга.
- Для альтернативных режимов работы выделен строгий строковый или булев литерал-дискриминант.
- Взаимоисключающие поля без явного дискриминанта снабжены типом
neverили обернуты вXOR. - Общие свойства вынесены в
BasePropsи объединены с ветками через пересечение (&). - Деструктуризация пропсов выполняется внутри тела компонента строго после сужения типа.
- Для проверки контрактов типов настроены type-level тесты.




.svg.webp)


