Перейти к основному содержимому

Требования к типизации

Цель

Типизация - обязательный механизм, обеспечивающий предсказуемость, безопасность рефакторинга и согласованность с 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

  • any
  • unknown без последующей проверки (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()

Минусы

  • ручная поддержка схем
  • нужна дисциплина: валидировать данные на входе
  • высокий риск рассинхронизации с 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

Правила для unknown, as и guards

MUST

  • unknown допустим только вместе с проверкой.
  • Вместо as предпочитать guards/type predicates.

✅ Хорошо (type guard)

interface UserDto {
id: string;
name: string;
}

export function isUserDto(input: unknown): input is UserDto {
if (typeof input !== 'object' || input === null) {
return false;
}

const v = input as Record<string, unknown>;

return typeof v.id === 'string' && typeof v.name === 'string';
}

// Использование
function handle(data: unknown) {
if (isUserDto(data)) {
console.log(data.name); // TypeScript знает тип
}
}

✅ Хорошо (assert)

export function assertIsUserDto(input: unknown): asserts input is UserDto {
if (!isUserDto(input)) {
throw new Error('Invalid UserDto');
}
}

// Использование
function handle(data: unknown) {
assertIsUserDto(data);
console.log(data.name); // TypeScript знает тип после assert
}

❌ Плохо

const user = payload as UserDto; // каст без проверки