Требования к типизации
Цель
Типизация - обязательный механизм, обеспечивающий предсказуемость, безопасность рефакторинга и согласованность с API.
Типы используются как контракт, а не как "подсказка для редактора".
Корректная типизация делает невалидные состояния невыразимыми.
Типы должны:
- описывать реальные данные, а не "желаемое поведение"
- синхронизироваться с API
- предотвращать расхождения между ожидаемыми и фактическими структурами данных
- упрощать навигацию и понимание предметной области проекта
Общие правила
MUST
- TypeScript используется в strict mode.
- Любые данные, полученные извне модуля (да же в пределах одного репозитория), считаются недоверенными.
- Runtime-валидация границ доверия обязательна:
- Любой внешний источник данных — это граница доверия: ответы API, WebSocket, localStorage, query параметры, PostMessage events.
- TypeScript доверяет типам на этапе компиляции, но не проверяет данные в runtime.
- Без валидации несоответствие контракту приводит к багам, которые проявляются далеко от источника проблемы.
- Инструменты: Zod, ArkType, Valibot
- Типизация не должна "маскировать" потенциально некорректные данные.
- Отсутствие уверенного типа (
unknown+ type guard/assert) предпочтительнее некорректного типа.
✅ Хорошо
import { z } from 'zod';
const UserDtoSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type UserDto = z.infer<typeof UserDtoSchema>;
// Runtime валидация на границе
async function fetchUser(id: string): Promise<UserDto> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// Проверяем данные перед использованием
return UserDtoSchema.parse(data);
}
❌ Плохо
// Просто кастим без проверки
async function fetchUser(id: string): Promise<UserDto> {
const response = await fetch(`/api/users/${id}`);
return await response.json() as UserDto; // Опасно!
}
SHOULD
- Брендированные типы (Branded Types) рекомендуется использовать для структурно идентичных значений (TypeScript примитивов).
Проблема:
type UserId = string;
type CompanyId = string;
function generateAnalyticsReport(userId: UserId) {
/* ... */
}
// TypeScript не видит разницы - обе строки!
const companyId: CompanyId = 'company-123';
generateAnalyticsReport(companyId); // ✅ Компилируется, но логически ошибка
Решение — Branded Types:
import { z } from 'zod';
const UserIdSchema = z.string().brand<'UserId'>();
type UserId = z.infer<typeof UserIdSchema>;
const OrderIdSchema = z.string().brand<'OrderId'>();
type OrderId = z.infer<typeof OrderIdSchema>;
Принципы:
- Определите базовые брендированные типы один раз
- Переиспользуйте их по всему проекту
- При использовании runtime-валидации — встраивайте бренды прямо в описание схем
FORBIDDEN
anyunknownбез последующей проверки (assert / type guard / schema validation)- type casting (
as) для обхода ошибок компилятораДопускается только в редких случаях, локально и с техническим обоснованием. Предпочтительнее - type predicates / guards.
- "типизация на глаз", не отражающая реальную структуру данных
Любая настройка генерации/типизационного процесса требует согласования с Tech Lead.
Описание подходов к типизации
1) GraphQL генерация DTO (при наличии GraphQL API)
Принцип
Если API построен на GraphQL, типы генерируются автоматически из GraphQL-схемы под каждый query/mutation.
Когда применяется
API построен на GraphQL
Плюсы
- GraphQL-схема — единственный источник правды
- максимальная синхронизация FE/BE
- меньше ручных ошибок и риска рассинхрона
- быстрый рефакторинг при изменениях API благодаря static typing
Минусы
- нужно поддерживать механизм генерации
- сложности при неполной или устаревшей схеме
Инструменты
- graphql-codegen
- Apollo Client
Согласование
Настройка генерации — с Tech Lead
2) OpenAPI генерация DTO (MUST, если OpenAPI актуальна)
Принцип
Если у бэкенда есть OpenAPI (Swagger) и она поддерживается актуальной, DTO типы обязаны генерироваться автоматически.
Когда применяется
Backend предоставляет актуальную OpenAPI документацию
Плюсы
- максимальная синхронизация FE/BE
- меньше ручных ошибок и риска рассинхрона
- быстрый рефакторинг при изменениях API благодаря static typing
Минусы
- нужно поддерживать механизм генерации
- возможны неудобные названия/структуры типов, решается маппингом (mapper layer)
- генерируемые типы могут быть недостаточно выразительными
Важное правило: generated types не коммитятся
MUST
- Сгенерированные типы не должны коммититься в репозиторий проекта, чтобы:
- не было "искушения" править их руками
- типы всегда соответствовали источнику истины
Рекомендация по структуре
shared/api/
generated/
openapi.generated.ts # ❌ НЕ коммитится, в .gitignore
# Генерируемый файл, не редактируется вручную
# Ручные изменения НЕ допускаются
openapi.ts # ✅ Fine-tuned контракты (опционально)
# Только при проблемах с выразительностью контрактов
Как обеспечиваем (пример политики)
✅ Хорошо
- generated папка добавлена в
.gitignore - генерация выполняется через
scripts - CI проверяет, что типы можно сгенерировать и они консистентны
Пример .gitignore
# generated API types (OpenAPI/GraphQL)
src/shared/api/generated/
packages/api/generated/
Пример package.json scripts
{
"scripts": {
"api:generate": "openapi-typescript ./openapi.json -o src/shared/api/generated/index.ts",
"api:check": "pnpm api:generate && tsc --noEmit"
}
}
Вариант api:check полезен для проверки успешной генерации в CI.
Инструменты
- openapi-typescript
- orval
Согласование
Настройка генерации — с Tech Lead
3) Schema-first + runtime-валидация (если OpenAPI нет или не источник истины)
Принцип
Типы выводятся из runtime-схем (z.infer<> / valibot) и данные валидируются на входе:
- ответы внешних API
- формы
- небезопасные источники данных
Принципы:
- Сначала runtime, потом compile-time
- Описываем схему валидации → получаем и проверку данных, и статические типы
Когда использовать
- Генерация недоступна
- OpenAPI отсутствует или не гарантирует актуальность
- много внешних источников данных
- критично ловить некорректные данные в runtime, а не только в compile-time
Плюсы
- типы и runtime-проверки из одного источника (schema)
- меньше случаев, когда TypeScript "верит, а данные сломаны"
- отлично подходит для форм и внешних API
- возможность выразить constraints, недоступные в TypeScript:
- Non-empty string:
z.string().min(1) - Positive integer:
z.number().int().positive() - Email:
z.string().email()
- Non-empty string:
Минусы
- ручная поддержка схем
- нужна дисциплина: валидировать данные на входе
- высокий риск рассинхронизации с backend
- односторонняя защита: backend не знает об описанных вами контрактах
- при наличии OpenAPI требуется поддерживать sanity-check тесты для обнаружения расхождений
Инструменты
- Zod
- Valibot
- ArkType
Согласование
Требуется согласование с Tech Lead
✅ Пример (Zod)
import { z } from 'zod';
// 1. Описываем схему
export const UserDtoSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().optional(),
});
// 2. Выводим тип
export type UserDto = z.infer<typeof UserDtoSchema>;
// 3. Функция валидации
export function parseUserDto(input: unknown): UserDto {
return UserDtoSchema.parse(input);
}
4) Ручные DTO-типы (fallback, только по согласованию)
Принцип
DTO описываются вручную на фронтенде в соответствии со схемами API (если они есть).
⚠️ Это fallback-опция, а не рекомендуемый подход
Когда допускается
- Другие подходы невозможны / избыточны
- OpenAPI отсутствует или типы организованы неверно со стороны backend
- API стабилен и редко меняется
- проект небольшой
Плюсы
- быстрый старт
- полный контроль над неймингом и структурой типов
Минусы
- высокий риск рассинхрона payload/response с backend API
- больше багов при изменениях API
- односторонняя защита: backend не знает об описанных вами контрактах
Обязательные условия (MUST)
- структура DTO отражает API-модули
- любые изменения API со гласуются с фронтенд-разработчиком
- подход согласуется с Tech Lead (особенно если ранее использовались другие подходы)
Согласование
Подход должен быть одобрен Tech Lead
Строгое разделение DTO и UI-типов
Принцип
Не смешивать типы API (DTO) с типами UI/клиентской логики.
API-типы (DTO) ≠ UI-типы
Почему:
- UI-компонентам часто нужны дополнительные поля (например isLoading, isSelected, viewState)
- смешивание создаёт путаницу между "что реально приходит" и "что нужно UI"
MUST
- Расширять DTO UI-логикой недопустимо
- При необходимости маппить:
DTO → UI-модель(mapUserDtoToUserModel) - Разделять уровни абстракции
✅ Хорошо
// DTO (контракт API)
export interface UserDto {
id: string;
name: string;
last_name: string;
created_at: string; // ISO
}
// UI model (для интерфейса)
export interface UserViewModel extends UserDto {
id: string;
fullName: string;
createdAt: Date;
isSelected: boolean;
}
// Mapper
function mapUserDtoToViewModel(
dto: UserDto,
isSelected: boolean
): UserViewModel {
return {
id: dto.id,
fullName: `${dto.first_name} ${dto.last_name}`, // нормализация
createdAt: new Date(dto.created_at), // преобразование формата
isSelected, // UI-логика отдельно
};
}
❌ Плохо
export interface UserDto {
id: string;
name: string;
isLoading: boolean; // ❌ UI-логика в DTO
}
Структура DTO должна отражать структуру API
MUST
Файловая структура DTO должна зеркалить структуру API-документации (modules/sections/routes), чтобы типы было легко находить.
При ручном описании DTO организуйте модули по ресурсам API:
| API | Модуль |
|---|---|
| /users, /users/:id | /shared/api/users |
| /orders, /orders/:id | /shared/api/orders |
Преимущества:
- Ускоряет навигацию по коду
- Упрощает Code Review
- Уменьшает количество ошибок при поиске типов
✅ Пример структуры
shared/api/
users/
user.dto.ts # UserDto, UpdateUserDto
queries.ts
orders/
order.dto.ts # OrderDto, CreateOrderDto
queries.ts
billing/
invoice.dto.ts # InvoiceDto
queries.ts