Как типизировать сложные generic компоненты в React с помощью TypeScript

Если вы создавали библиотеку компонентов или работали над масштабным Enterprise-проектом, то наверняка сталкивались с ситуацией: вам нужен компонент, который работает с любыми данными, но при этом должен «помнить», с каким именно типом данных он сейчас работает.
Классический пример — Select, Table или List. Вы передаете туда массив объектов, и хотите, чтобы при выборе элемента в колбэке onChange TypeScript точно знал, что в event.value лежит именно ваш User или Product, а не абстрактный any или unknown.
Здесь на сцену выходят Generic-компоненты. Но как только логика усложняется (добавляются сложные пропсы, рендер-функции или зависимости между типами), стандартный синтаксис <T> начинает сыпаться. В этой статье разберем, как строить архитектуру типов так, чтобы IDE помогала вам, а не засыпала ошибками.
Почему any — это путь в никуда, а unknown — не всегда решение
Многие разработчики, столкнувшись с Generic-компонентами, пытаются упростить себе жизнь, используя any. Это убивает весь смысл использования TypeScript. Другие используют unknown, что безопаснее, но заставляет писать бесконечные проверки if (typeof ... === 'string') или приведения типов (as MyType), что превращает код в «помойку» из кастов.
Правильный Generic-подход позволяет создать «сквозную типизацию»: тип данных, который пришел на вход компонента, должен автоматически распространиться на все внутренние методы и выходящие события.
Базовый паттерн: Создание простого Generic-компонента
Начнем с основы. Чтобы компонент стал generic, мы должны объявить параметр типа перед объявлением самого компонента.
Важный нюанс: в .tsx файлах синтаксис <T> конфликтует с JSX-тегами. Чтобы TypeScript не подумал, что вы пытаетесь отрендерить тег <T>, используйте запятую <T,> или расширение extends {}.
tsx
interface ListProps {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
function List({ items, renderItem }: ListProps) {
return (
-
- {items.map((item, index) => (
- {renderItem(item)}
))}
);
}
Этот пример прост, но в реальных проектах нам часто нужно ограничить T. Например, чтобы компонент Table мог работать с любым объектом, но только если у этого объекта есть поле id.
Ограничение типов через extends (Constraints)
Когда мы говорим T extends { id: string | number }, мы сообщаем компилятору: «Я не знаю точно, что за объект придет, но я гарантирую, что у него будет уникальный идентификатор». Это позволяет безопасно использовать item.id внутри компонента без использования any.
Как правильно типизировать сложные пропсы:
-
- Определите базовый интерфейс ограничений. Создайте интерфейс, который описывает минимальные требования к данным.
-
- Используйте Generic в интерфейсе пропсов. Пропсы должны быть связаны с тем же типом
T, что и сам компонент.
- Используйте Generic в интерфейсе пропсов. Пропсы должны быть связаны с тем же типом
-
- Связывайте типы колбэков. Все функции-обработчики должны принимать
Tв качестве аргумента.
- Связывайте типы колбэков. Все функции-обработчики должны принимать
tsx
interface Identifiable {
id: string | number;
}
interface DataTableProps {
data: T[];
onRowClick: (item: T) => void;
columns: {
header: string;
accessor: keyof T; // Магия: accessor может быть только ключом из типа T
render?: (value: any, item: T) => React.ReactNode;
}[];
}
const DataTable = ({ data, onRowClick, columns }: DataTableProps) => {
return (
{columns.map(col =>)}
{data.map(item => (onRowClick(item)}>{columns.map(col => ())}
))}
| {col.header} |
|---|
| {col.render ? col.render(item[col.accessor], item) : String(item[col.accessor])} |
);
};
Проблема с React.FC и Generic-типами
Многие привыкли использовать React.FC (или React.FunctionalComponent). Однако React.FC плохо дружит с Generic-типами. Если вы попытаетесь написать const MyComp: React.FC<Props<T>>, вы обнаружите, что T не определен.
Решение: Откажитесь от React.FC в пользу обычных функций. Это дает полный контроль над generic-параметрами и делает код чище.
Продвинутые техники: Сложные зависимости и Mapped Types
Бывают случаи, когда тип одного пропса зависит от типа другого. Например, если вы передаете массив данных одного типа, то функция фильтрации должна принимать этот же тип.
Работа с keyof и индексацией
Если ваш компонент должен динамически изменять или отображать данные по ключу, используйте keyof T. Это позволяет избежать ошибок опечаток в названиях полей. Если вы переименуете поле в интерфейсе данных, TypeScript подсветит ошибку во всех местах, где этот ключ используется в пропсах компонента.
Пример с использованием Pick и Partial
Иногда нам нужно, чтобы компонент принимал не весь объект T, а только его часть. В этом случае в помощь приходят утилиты TypeScript:
tsx
interface FilterableProps {
data: T[];
filterKey: keyof T;
filterValue: T[keyof T]; // Значение должно соответствовать типу выбранного ключа
}
Практические советы по отладке Generic-компонентов
Когда вы строите сложную систему типов, иногда TypeScript начинает выдавать ошибки, которые выглядят как «белый шум» (огромные сообщения о несоответствии типов). Вот как с этим бороться:
- Разбивайте интерфейсы. Не пытайтесь создать один гигантский интерфейс. Разделите их на
BaseProps,RenderPropsиEventProps. - Используйте вспомогательные типы (Helper Types). Если логика определения типа становится слишком сложной, вынесите её в отдельный
typeилиinterface. - Проверяйте вывод типов (Type Inference). Наведите курсор на переменную в месте вызова компонента. Если вы видите
Tилиunknown, значит, TypeScript не смог вывести тип автоматически, и вам нужно либо уточнить пропсы, либо явно передать тип:<DataTable<User> ... />. - Избегайте
anyдаже в рендер-функциях. Если вы не знаете тип значения, используйтеunknownи сужайте его с помощью Type Guards (if (typeof value === 'string')).
Итог: Архитектурный подход
Типизация сложных Generic-компонентов — это баланс между строгостью и гибкостью. Если сделать систему слишком строгой, вы будете тратить больше времени на борьбу с компилятором, чем на написание кода. Если слишком гибкой — потеряете все преимущества TS.
Золотое правило: Generic-компоненты должны быть «прозрачными». Данные входят в компонент в типе T и выходят из него в том же типе T. Всё, что происходит внутри — это лишь преобразование или отображение, которое не должно разрывать эту цепочку типов.
Следование этим принципам позволяет создавать переиспользуемые UI-киты, которые будут понятны любому разработчику в команде, а автодополнение в IDE станет вашим главным инструментом, а не источником раздражения.

