Backend

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

Как типизировать сложные 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.

Как правильно типизировать сложные пропсы:

 

    1. Определите базовый интерфейс ограничений. Создайте интерфейс, который описывает минимальные требования к данным.

 

    1. Используйте Generic в интерфейсе пропсов. Пропсы должны быть связаны с тем же типом T, что и сам компонент.

 

    1. Связывайте типы колбэков. Все функции-обработчики должны принимать 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 начинает выдавать ошибки, которые выглядят как «белый шум» (огромные сообщения о несоответствии типов). Вот как с этим бороться:

  1. Разбивайте интерфейсы. Не пытайтесь создать один гигантский интерфейс. Разделите их на BaseProps, RenderProps и EventProps.
  2. Используйте вспомогательные типы (Helper Types). Если логика определения типа становится слишком сложной, вынесите её в отдельный type или interface.
  3. Проверяйте вывод типов (Type Inference). Наведите курсор на переменную в месте вызова компонента. Если вы видите T или unknown, значит, TypeScript не смог вывести тип автоматически, и вам нужно либо уточнить пропсы, либо явно передать тип: <DataTable<User> ... />.
  4. Избегайте any даже в рендер-функциях. Если вы не знаете тип значения, используйте unknown и сужайте его с помощью Type Guards (if (typeof value === 'string')).

Итог: Архитектурный подход

Типизация сложных Generic-компонентов — это баланс между строгостью и гибкостью. Если сделать систему слишком строгой, вы будете тратить больше времени на борьбу с компилятором, чем на написание кода. Если слишком гибкой — потеряете все преимущества TS.

Золотое правило: Generic-компоненты должны быть «прозрачными». Данные входят в компонент в типе T и выходят из него в том же типе T. Всё, что происходит внутри — это лишь преобразование или отображение, которое не должно разрывать эту цепочку типов.

Следование этим принципам позволяет создавать переиспользуемые UI-киты, которые будут понятны любому разработчику в команде, а автодополнение в IDE станет вашим главным инструментом, а не источником раздражения.