Практика TypeScript: как типизировать доменные модели и избежать рассинхрона UI/API
На примерах разберём, как держать единые схемы данных между фронтом и бэкендом: типы для входа/выхода, дискриминируемые объединения, безопасные маппинги и стратегии миграций без поломок рантайма.
Содержание
Практика TypeScript: как типизировать доменные модели и избежать рассинхрона UI/API
Рассинхрон между UI и API — одна из самых дорогих проблем в веб-разработке. Она проявляется не сразу: сегодня компонент рендерит поле, которое “случайно” вернул бэк, а завтра сервис меняет формат ответа — и вы получаете тихие ошибки, частично заполненные формы, неправильные статусы кнопок и, как следствие, недоверие к интерфейсу. В TypeScript эта боль лечится не “магией типов”, а дисциплиной: доменные модели должны иметь единый смысловой источник и предсказуемые границы между входом/выходом, хранилищем/транспортом и UI-видом.
В этой статье разберём практический подход к типизации доменных моделей в TypeScript: как разделять DTO и доменные сущности, как формировать вход/выход API, как использовать дискриминируемые объединения, как писать безопасные маппинги и как планировать миграции без поломок рантайма. Все примеры — на уровне, который можно перенести в реальный проект без “учебной” упрощённости.
Типы для домена: где обычно возникает рассинхрон
Перед тем как писать типы, полезно понять, почему рассинхрон вообще происходит:
- Смешение слоёв: один и тот же интерфейс используется и для UI-состояния, и для API-ответа, и для внутренней доменной логики. В результате любое “косметическое” изменение одного слоя тянет за собой цепочку правок.
- Нет явных границ: тип описывает “что-то похожее”, но не фиксирует контракт. Например, UI ожидает
status: "active" | "paused", а API может прислатьstatus: "ACT",nullили вообще другое поле — и компилятор не помогает, потому что типизация слишком широкая. - Отсутствие различения состояний: “один тип на все случаи” превращается в набор необязательных полей, и UI начинает ветвиться по факту наличия данных, а не по явной модели состояния.
- Мэппинги без инвариантов: разработчики вручную переписывают поля в нескольких местах и надеются, что структура совпадёт. Это работает “до первого исключения”.
TypeScript хорошо решает эти проблемы, если вы задаёте строгие контракты и фиксируете переходы между моделями.
Архитектурная схема: транспортные типы и доменные модели
Практическая рекомендация: держите как минимум два слоя типов:
- Transport (DTO): структура данных, которую вы реально отправляете/получаете с сервера. Это то, что вы привязываете к API-контракту.
- Domain (модель): ваши смысловые сущности и состояния, пригодные для бизнес-логики и UI.
Важно: доменная модель не должна “случайно” совпадать с DTO. Её смысл — быть удобной и безопасной для использования внутри приложения.
Минимальный каркас
// DTO: данные для API (transport contract)
export type UserDto = {
id: string;
email: string;
plan: "free" | "pro" | "enterprise";
isActive: boolean;
};
// Domain: доменная модель (internal contract)
export type User = {
id: string;
email: string;
plan: "free" | "pro" | "enterprise";
active: boolean;
};
Даже здесь видно отличие: DTO использует isActive, домен — active. Это уже защищает от ситуации, когда UI “привязался” к именованию поля на сервере.
Вход и выход API: типизируем контракты без размытия
Ещё один источник рассинхрона — когда один тип описывает и request, и response, или когда request строится из того же объекта, что пришёл в ответ.
Правильнее разделять:
CreateUserRequest— что отправляемCreateUserResponse— что получаемUser— что используем внутри приложения
Пример: создание пользователя
// DTO: request
export type CreateUserRequestDto = {
email: string;
plan: "free" | "pro" | "enterprise";
};
// DTO: response
export type CreateUserResponseDto = {
id: string;
email: string;
plan: "free" | "pro" | "enterprise";
isActive: boolean;
};
Плюс, если API может вернуть ошибку, это тоже лучше описать явно (и не превращать в any).
Дискриминируемые объединения: фиксируем состояния, а не “опциональные поля”
Типизация состояний — самый практичный инструмент против рассинхрона UI и API. Там, где “по идее” есть несколько вариантов, лучше использовать discriminated union:
- один общий тип
- поле-дискриминатор (например,
kindилиtype) - вариации структуры под каждый случай
Пример: загрузка сущности пользователем
Допустим, API возвращает либо “профиль активен”, либо “пользователь не активирован” с другой структурой.
// DTO: возможные формы ответа
export type UserProfileResponseDto =
| { status: "active"; user: { id: string; email: string; plan: string } }
| { status: "inactive"; user: { id: string; email: string }; reason: string };
// Domain: то же состояние, но уже адаптировано
export type UserProfile =
| { kind: "active"; user: { id: string; email: string; plan: "free" | "pro" | "enterprise" } }
| { kind: "inactive"; user: { id: string; email: string }; reason: string };
Почему это важно: UI не должен проверять “есть ли reason” или “существует ли план”. Вместо этого он ветвится по дискриминатору.
Маппинг из DTO в domain
export function mapUserProfileDtoToDomain(dto: UserProfileResponseDto): UserProfile {
switch (dto.status) {
case "active":
return {
kind: "active",
user: {
id: dto.user.id,
email: dto.user.email,
plan: dto.user.plan as UserProfile["kind"] extends any ? any : never, // (в реальном коде лучше валидировать)
},
};
case "inactive":
return {
kind: "inactive",
user: {
id: dto.user.id,
email: dto.user.email,
},
reason: dto.reason,
};
}
}
Этот код иллюстрирует идею ветвления. В реальном проекте вместо as нужно делать валидацию/нормализацию (см. раздел про миграции и рантайм-защиту). Но даже сама структура ветвления уже делает UI устойчивее: вы гарантируете, что “active”-ветка всегда содержит plan, а “inactive” — всегда содержит reason.
Безопасные маппинги: инварианты и запрет “магии as”
TypeScript не знает реального мира API: он опирается на ваши типы. Поэтому “безопасность” маппинга — это не только про компилятор, но и про рантайм-вычисления: когда API прислал неожиданное значение, вы либо преобразуете его в допустимую форму, либо корректно откажетесь.
Валидация enum-подобных значений
Допустим, plan в DTO приходит как строка, но только три значения допустимы. На домене — строгий union.
export const PlanValues = ["free", "pro", "enterprise"] as const;
export type Plan = (typeof PlanValues)[number];
export function parsePlan(value: string): Plan {
if (PlanValues.includes(value as Plan)) return value as Plan;
throw new Error(`Unknown plan: ${value}`);
}
Да, это не “нулевой” код, но это тот случай, когда рантайм-защита экономит время: лучше упасть контролируемо в момент маппинга, чем показать пользователю некорректное поведение.
Полный маппинг
export type UserProfileResponseDto =
| { status: "active"; user: { id: string; email: string; plan: string } }
| { status: "inactive"; user: { id: string; email: string }; reason: string };
export type UserProfile =
| { kind: "active"; user: { id: string; email: string; plan: Plan } }
| { kind: "inactive"; user: { id: string; email: string }; reason: string };
export function mapUserProfileDtoToDomain(dto: UserProfileResponseDto): UserProfile {
switch (dto.status) {
case "active":
return {
kind: "active",
user: {
id: dto.user.id,
email: dto.user.email,
plan: parsePlan(dto.user.plan),
},
};
case "inactive":
return {
kind: "inactive",
user: {
id: dto.user.id,
email: dto.user.email,
},
reason: dto.reason,
};
}
}
Теперь если сервер вдруг отдаст plan: "starter", вы получите понятную ошибку в маппинге. А UI сможет корректно обработать ошибку на уровне слоя загрузки.
Разделяем UI-модель и доменную модель
Ещё один частый источник рассинхрона: UI “обрастает” полями, которые не входят в домен. Например:
isSelected— состояние компонентаvalidationErrors— локальные ошибки формыdisplayName— вычисляемое поле
Не надо засорять домен UI-деталями. Создайте прослойку: view model.
Пример: карточка пользователя
export type UserCardViewModel = {
id: string;
title: string;
subtitle: string;
badge: "Активен" | "Не активирован";
reason?: string;
onActionLabel: string;
};
Преобразование из домена в view model:
export function toUserCardVm(profile: UserProfile): UserCardViewModel {
switch (profile.kind) {
case "active":
return {
id: profile.user.id,
title: profile.user.email,
subtitle: `Тариф: ${profile.user.plan}`,
badge: "Активен",
onActionLabel: "Управлять",
};
case "inactive":
return {
id: profile.user.id,
title: profile.user.email,
subtitle: "Профиль не активирован",
badge: "Не активирован",
reason: profile.reason,
onActionLabel: "Отправить повторно",
};
}
}
UI уже не знает ничего про DTO, а domain остаётся стабильным.
Контракты ошибок: типизируем не только “успех”, но и сбои
Даже при идеальном маппинге ошибки неизбежны: сеть, таймауты, 4xx/5xx, частичные данные. Если не типизировать ошибки, они “поплывут” в виде any, строк или произвольных объектов.
Практичный шаблон — унифицированный результат:
export type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: ApiError };
export type ApiError =
| { type: "NetworkError"; message: string }
| { type: "HttpError"; status: number; message?: string }
| { type: "DecodeError"; message: string };
Тогда слой загрузки получает предсказуемую структуру и UI ветвится по type, а не по строкам.
Миграции без поломок рантайма: стратегии изменения API и схем
Отдельная реальность: API меняется. Даже если TypeScript помогает на этапе компиляции, он не спасёт от релиза, где бэкенд и фронт обновляются не синхронно.
Ниже — несколько стратегий, которые реально помогают.
Стратегия 1: версия контракта и адаптеры
Если API меняет формат, добавляйте версионирование — даже если оно не явное в URL, оно может быть “скрытым” в дискриминаторе.
Пример: раньше status был "active" | "inactive", теперь добавили "suspended" и другое поле suspension.
DTO становится объединением:
export type UserProfileResponseDto =
| { status: "active"; user: { id: string; email: string; plan: string } }
| { status: "inactive"; user: { id: string; email: string }; reason: string }
| { status: "suspended"; user: { id: string; email: string }; suspension: { until: string } };
Domain — тоже расширяемое объединение:
export type UserProfile =
| { kind: "active"; user: { id: string; email: string; plan: Plan } }
| { kind: "inactive"; user: { id: string; email: string }; reason: string }
| { kind: "suspended"; user: { id: string; email: string }; until: Date };
Маппинг дополняется веткой. Ключевой плюс: UI, который использует switch (profile.kind), заставляет вас обработать новый кейс. Если вы забыли — компилятор подскажет.
Стратегия 2: “мягкая” поддержка новых полей (backward/forward compatibility)
Иногда изменения добавляют поле, которое не используется фронтом. Тогда DTO можно расширить:
export type UserDtoV1 = {
id: string;
email: string;
plan: "free" | "pro" | "enterprise";
};
export type UserDtoV2 = UserDtoV1 & {
// новое поле, которое UI пока не использует
marketingOptIn?: boolean;
};
Маппинг остаётся совместимым, а view model — прежней. Проблема только в обратном сценарии: когда вы начинаете требовать поле, которое может отсутствовать в старой версии ответа.
Здесь важно не моделировать “обязательное” поле как обязательное без гарантии.
Стратегия 3: runtime-совместимость через декодинг
Если вы ожидаете поле, которое может прийти в разных формах (например, plan может быть строкой или объектом), вы обязаны декодировать на рантайме.
Пример декодера:
type PlanInput = string | { code: string };
export function decodePlan(input: PlanInput): Plan {
const raw = typeof input === "string" ? input : input.code;
if (PlanValues.includes(raw as Plan)) return raw as Plan;
throw new Error(`Unknown plan: ${raw}`);
}
Даже если TypeScript на уровне типов “предположит”, что пришло нужное, реальность может быть другой. Декодинг — это способ сделать реальность согласованной с типами.
Типизация маппингов: модель “вход — выход” и фабрика
Чтобы маппинги не превращались в разрозненные функции “где-то рядом”, полезно стандартизировать подход:
- у каждого преобразования есть явные типы входа/выхода
- маппинг содержит инварианты (валидации)
- view model формируется отдельно
Шаблон:
export type Mapper<I, O> = (input: I) => O;
export const mapUserDto: Mapper<UserDto, User> = (dto) => ({
id: dto.id,
email: dto.email,
plan: dto.plan,
active: dto.isActive,
});
Для сложных структур маппинг стоит писать через функции-декодеры:
export function decodeUserProfile(dto: UserProfileResponseDto): UserProfile {
// switch по дискриминатору + parsePlan + parseDates и т.д.
return mapUserProfileDtoToDomain(dto);
}
Главная цель — сделать невозможным “тихий” рассинхрон: если декодер не может преобразовать данные, вы сигнализируете об ошибке.
Типизируем UI: как избежать ветвлений по наличию полей
Ещё одна частая ошибка — UI проверяет опциональность:
if (profile.reason) { ... } // слабая проверка
Вместо этого используйте дискриминатор:
switch (profile.kind) {
case "active": { ...; break; }
case "inactive": { ...; break; }
case "suspended": { ...; break; }
}
Тогда:
- UI обязан обработать все варианты (или явно проигнорировать с оговоркой)
- изменение доменной модели приводит к понятным compile-time ошибкам
- меньше случайных
undefinedв рантайме
Ошибка дизайна: один тип для всего и “опциональности на максимум”
Иногда кажется удобным сделать один тип:
export type UserProfileBad = {
id: string;
email: string;
status?: "active" | "inactive" | "suspended";
plan?: Plan;
reason?: string;
};
Это выглядит гибко, но ломает ключевые преимущества TypeScript:
- вы не можете гарантировать консистентность (
planможет быть безstatus,reasonможет быть в active-ветке) - UI будет постоянно делать проверки на наличие полей
- новые кейсы усложняют логики ветвления без компиляторной помощи
Дискриминируемые объединения решают это структурно.
Практический рецепт: как внедрить подход в существующий проект
Если вы уже живёте с рассинхроном, подход “переписать всё” обычно не проходит. Надёжнее внедрять поэтапно.
Шаг 1: зафиксируйте DTO и отделите их от домена
- Найдите места, где UI напрямую использует данные с API
- Создайте DTO типы (request/response)
- Сделайте первый слой маппинга в домен
На этом этапе UI можно оставить прежним, но data должна проходить через маппинг.
Шаг 2: добавьте дискриминируемые состояния там, где есть ветвления
Если в UI уже есть условные проверки вида if (data.reason), вероятно, это кандидат на discriminated union.
Шаг 3: начните вводить декодеры для “опасных” полей
Это обычно:
- enum-поля (строки с фиксированным набором)
- даты
- числовые поля, которые могут быть строками в JSON
- поля, которые иногда отсутствуют
Не пытайтесь декодировать всё сразу. Начните с самых частых источников багов.
Шаг 4: стандартизируйте маппинги и ошибки
Единый формат результата (ApiResult) и единый слой декодинга резко снижают количество “самодельных” проверок в компонентах.
Что делать с “генерацией типов” и схемами
Есть соблазн пойти дальше: OpenAPI/GraphQL generate, JSON Schema, zod io-ts и т.д. Это полезные инструменты, но важно сохранить смысловую архитектуру:
- даже с генерацией вам всё равно нужны границы DTO ↔ domain ↔ view model
- генерация помогает типизировать контракт, но не заменяет необходимость валидировать/декодировать под ваши инварианты
- доменная модель всё равно должна быть удобной и безопасной для бизнес-логики
Генерация типов — ускоритель. Архитектура — защита.
Итоги: как TypeScript реально сокращает рассинхрон UI/API
Если свести всё в практический чеклист, получится следующее:
- Разделяйте DTO и доменные модели. UI не должен зависеть от именования и формы ответов сервера.
- Типизируйте request и response отдельно. Не делайте один “универсальный” тип.
- Используйте дискриминируемые объединения для состояний. Это заменяет хаотичные проверки опциональных полей.
- Пишите безопасные маппинги с рантайм-инвариантами. Особенно для enum-подобных строк, дат и структур, которые могут отличаться между релизами.
- Отделяйте view model от домена. UI-данные (валидации, selection, display) не должны загрязнять доменные типы.
- Планируйте миграции через расширение объединений и адаптеры. Тогда добавление нового статуса превращается в компиляторную подсказку, а не в скрытую ошибку.
- Держите ошибки типизированными. Это снижает количество
unknownи “магических” веток в компонентах.
Если вы хотите глубже прокачать именно прикладной TypeScript (дискриминируемые объединения, типизация API, паттерны маппинга и безопасные преобразования), хорошо подойдёт курс TypeScript - для начинающих — но ценность там будет максимальной только в том случае, если вы параллельно переносите эти идеи в свой реальный проект, а не ограничиваетесь примерами.
Если хотите, могу предложить шаблон структуры папок и типового “контракта” (DTO/domain/view) для конкретного стека (React/Vue, fetch/axios, state management, OpenAPI/REST).
Комментарии
Пока нет комментариев