Архитектура и качество: generics, утилитные типы, тестирование
Связь с предыдущими материалами
Ранее мы настроили проект, научились типизировать компоненты и хуки, работать с формами и безопасно взаимодействовать с API через DTO, маппинг и модели состояния загрузки.
Следующий шаг в практической разработке фронтенда: сделать так, чтобы кодовая база оставалась поддерживаемой при росте функциональности. Для этого нужны:
архитектурные границы (где живут типы, API, фичи и компоненты)
переиспользуемые типовые абстракции через generics и утилитные типы
автоматическая проверка качества тестамиПолезные ссылки:
TypeScript Handbook
TypeScript Utility Types
Vitest
React Testing Library
Testing Library: guiding principlesАрхитектура в TypeScript + React: где должны жить типы и логика
Хорошая архитектура во фронтенде обычно отвечает на два вопроса:
где находится единый источник правды для контрактов (типов) и правил преобразования данных
как сделать так, чтобы изменения локализовались и не заставляли править десятки файловПрактическое правило слоёв
Во всех предыдущих статьях мы постепенно пришли к схеме:
API возвращает DTO
приложение преобразует DTO в модель UI/домена
компоненты рендерят уже UI-модель, а не DTOЭто важно по качеству:
DTO может меняться независимо от UI
UI получает удобные структуры (например, Date, нормальные названия полей)
TypeScript-ошибки появляются ближе к месту изменения (маппер), а не размазываются по всему приложениюРекомендуемая структура проекта
Один из удобных вариантов для небольшого и среднего приложения:
src/api/ — слой запросов и ошибок
src/features/<feature>/ — фичи, содержащие типы, мапперы, хуки и компоненты
src/shared/ — переиспользуемые UI-компоненты и утилитыПример:
!Визуальная карта слоёв и направлений зависимостей
Ключевое правило зависимостей:
shared не зависит от features
features может зависеть от shared и api
компоненты не должны содержать логики преобразования DTOGenerics как инструмент архитектуры
Generics (дженерики) позволяют написать тип и функцию так, чтобы они работали с разными данными, но сохраняли строгую типизацию. Во фронтенде это особенно важно для:
API-обёрток
моделей удалённых данных (loading, success, error)
переиспользуемых хуков
утилит для формУниверсальный тип состояния загрузки
В статье про API мы использовали объединение статусов. Вынесем его в shared/lib/remoteData.ts и сделаем обобщённым:
Теперь в любой фиче можно писать:
Плюс качества: компонент физически не сможет обратиться к data, пока status не станет success.
Обобщённая обёртка над запросом JSON
Если у вас есть единая функция запросов, её можно сделать обобщённой по типу полезной нагрузки.
Важно для качества:
unknown подчёркивает, что данные пришли в runtime
приведение as T лучше концентрировать в одном месте (слой API)
если проект растёт, добавляйте runtime-проверку (например, схемами)Для схем и runtime-валидации часто используют:
ZodGeneric-маппер DTO в модель
Чтобы не дублировать шаблон преобразования, можно стандартизировать контракт маппера.
Использование:
Плюс качества: любой маппер читается одинаково, проще тестировать и искать по проекту.
Обобщённый setField для форм
Из статьи про формы у нас был типобезопасный setField. Это типичный пример полезного generics в UI.
Что делает K extends keyof T:
key может быть только ключом объекта T
тип value автоматически зависит от конкретного ключаУтилитные типы как способ держать контракты в порядке
Утилитные типы TypeScript помогают описывать реальные сценарии фронтенда без ручного дублирования типов.
Самые практичные утилиты и где они применяются
| Утилитный тип | Что делает | Типичный фронтенд-кейс |
|---|---|---|
| Partial<T> | делает поля опциональными | состояние формы редактирования |
| Required<T> | делает поля обязательными | нормализованные данные после валидации |
| Pick<T, K> | выбирает часть полей | карточка/превью в списке |
| Omit<T, K> | исключает поля | payload создания без id |
| Record<K, V> | словарь | тексты для статусов/ролей |
| ReturnType<F> | тип результата функции | вывод типов из фабрик/селекторов |
| Parameters<F> | тип параметров функции | проксирование и обёртки |
| Awaited<T> | извлекает тип из Promise | вывод типа данных из async-функций |
| NonNullable<T> | убирает null и undefined | после явной проверки |
| Extract<A, B> | оставляет пересечение union | выделение конкретной ветки состояния |
| Exclude<A, B> | убирает из union | запрет части значений |
Документация:
TypeScript Utility TypesПример: тип ошибок формы, связанный со значениями
Плюс качества: если вы переименовали поле в LoginValues, TypeScript сразу покажет места, где нужно обновить ошибки.
Пример: DTO и UI-модель и правильные payload-типы
Плюс качества: вы не копируете руками типы для похожих сценариев.
Оператор satisfies для более строгих проверок
satisfies проверяет, что объект соответствует контракту, но при этом не расширяет тип объекта до контракта целиком. Это удобно для конфигов и словарей.
Плюс качества:
если вы забыли ключ, TypeScript покажет ошибку
если вы добавили лишний ключ, TypeScript тоже покажет ошибкуПодробнее:
TypeScript 4.9 satisfies operatorТехника качества: исчерпывающая проверка веток
Когда вы используете дискриминирующие объединения (например, RemoteData<T>), важно гарантировать, что вы обработали все случаи. Это полезно для reducers, маппинга статусов и отображения.
Утилита assertNever обычно живёт в shared/lib/assertNever.ts:
Пример применения:
Плюс качества: если вы добавите новый статус, TypeScript заставит обновить switch.
Тестирование: как проверять качество в TypeScript + React
Тесты во фронтенде обычно делят на:
юнит-тесты для чистых функций (мапперы, валидаторы)
компонентные тесты для UI-логики (рендер, ввод, клики)
интеграционные тесты для связок (форма + запрос + отображение ошибки)Главная идея тестирования в React:
тестируйте поведение, которое видит пользователь
меньше проверяйте внутренние детали реализацииЭто совпадает с принципами Testing Library:
Testing Library: guiding principlesИнструменты
Практичный набор для Vite-проектов:
Vitest как тест-раннер
React Testing Library для рендера и взаимодействия
jest-dom для удобных матчеровПример: тестируем чистую функцию (маппер)
Почему это важно архитектурно:
мапперы изолируют изменения DTO
тест на маппер быстро покажет, что контракт поменялсяПример: тестируем компонент формы как пользователь
Ниже упрощённый пример, где важна типобезопасная логика валидации, а тест проверяет поведение: кнопка выключена, ошибка появляется после blur.
Ключевые практики качества:
использовать getByRole и getByLabelText, а не селекторы по классу
взаимодействовать через userEvent, а не вручную вызывать обработчикиТестирование запросов без реального сервера
Есть два популярных подхода:
мокать fetch напрямую в тестах
использовать слой перехвата запросов на уровне сети, чтобы тесты были ближе к реальностиДля второго часто используют:
Mock Service WorkerПлюс качества: вы тестируете сценарий целиком (загрузка, ошибка, повтор), не привязываясь к реализации запроса.
Мини-чеклист качества для реального проекта
npm run typecheck проходит без ошибок
типы DTO не протекают в JSX, есть мапперы в одном месте
есть общий RemoteData<T> или аналог для статусов загрузки
утилитные типы (Pick, Omit, Partial, Record) используются вместо ручного копирования
reducers и рендеринг статусов используют исчерпывающую проверку
есть тесты на мапперы и валидаторы
есть хотя бы несколько тестов поведения для ключевых компонентовЧто дальше
На базе архитектурных границ, generics, утилитных типов и тестов становится проще масштабировать приложение:
выделять фичи и переиспользуемые части
безопасно менять контракты API
переносить UI-логику в хуки и сервисы без потери уверенностиСледующий практический уровень обычно связан с унификацией работы с данными: кеширование, повторные запросы, общие хуки загрузки и стратегий ошибок, но даже без дополнительных библиотек описанные принципы дают сильный фундамент качества.