$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогПрактика TypeScript: как типизировать доменные модели и избежать рассинхрона UI/API

Практика TypeScript: как типизировать доменные модели и избежать рассинхрона UI/API

$ sudo teach IT
·11 августа 2026 г.·12 мин·37
Практика TypeScript: как типизировать доменные модели и избежать рассинхрона UI/API

На примерах разберём, как держать единые схемы данных между фронтом и бэкендом: типы для входа/выхода, дискриминируемые объединения, безопасные маппинги и стратегии миграций без поломок рантайма.

Содержание
Типы для домена: где обычно возникает рассинхронАрхитектурная схема: транспортные типы и доменные моделиМинимальный каркасВход и выход API: типизируем контракты без размытияПример: создание пользователяДискриминируемые объединения: фиксируем состояния, а не “опциональные поля”Пример: загрузка сущности пользователемМаппинг из DTO в domainБезопасные маппинги: инварианты и запрет “магии as”Валидация enum-подобных значенийПолный маппингРазделяем UI-модель и доменную модельПример: карточка пользователяКонтракты ошибок: типизируем не только “успех”, но и сбоиМиграции без поломок рантайма: стратегии изменения API и схемСтратегия 1: версия контракта и адаптерыСтратегия 2: “мягкая” поддержка новых полей (backward/forward compatibility)Стратегия 3: runtime-совместимость через декодингТипизация маппингов: модель “вход — выход” и фабрикаТипизируем UI: как избежать ветвлений по наличию полейОшибка дизайна: один тип для всего и “опциональности на максимум”Практический рецепт: как внедрить подход в существующий проектШаг 1: зафиксируйте DTO и отделите их от доменаШаг 2: добавьте дискриминируемые состояния там, где есть ветвленияШаг 3: начните вводить декодеры для “опасных” полейШаг 4: стандартизируйте маппинги и ошибкиЧто делать с “генерацией типов” и схемамиИтоги: как TypeScript реально сокращает рассинхрон UI/API

Рассинхрон между UI и API — одна из самых дорогих проблем в веб-разработке. Она проявляется не сразу: сегодня компонент рендерит поле, которое “случайно” вернул бэк, а завтра сервис меняет формат ответа — и вы получаете тихие ошибки, частично заполненные формы, неправильные статусы кнопок и, как следствие, недоверие к интерфейсу. В TypeScript эта боль лечится не “магией типов”, а дисциплиной: доменные модели должны иметь единый смысловой источник и предсказуемые границы между входом/выходом, хранилищем/транспортом и UI-видом.

В этой статье разберём практический подход к типизации доменных моделей в TypeScript: как разделять DTO и доменные сущности, как формировать вход/выход API, как использовать дискриминируемые объединения, как писать безопасные маппинги и как планировать миграции без поломок рантайма. Все примеры — на уровне, который можно перенести в реальный проект без “учебной” упрощённости.


Типы для домена: где обычно возникает рассинхрон

Перед тем как писать типы, полезно понять, почему рассинхрон вообще происходит:

  1. Смешение слоёв: один и тот же интерфейс используется и для UI-состояния, и для API-ответа, и для внутренней доменной логики. В результате любое “косметическое” изменение одного слоя тянет за собой цепочку правок.
  2. Нет явных границ: тип описывает “что-то похожее”, но не фиксирует контракт. Например, UI ожидает status: "active" | "paused", а API может прислать status: "ACT", null или вообще другое поле — и компилятор не помогает, потому что типизация слишком широкая.
  3. Отсутствие различения состояний: “один тип на все случаи” превращается в набор необязательных полей, и UI начинает ветвиться по факту наличия данных, а не по явной модели состояния.
  4. Мэппинги без инвариантов: разработчики вручную переписывают поля в нескольких местах и надеются, что структура совпадёт. Это работает “до первого исключения”.

TypeScript хорошо решает эти проблемы, если вы задаёте строгие контракты и фиксируете переходы между моделями.


Архитектурная схема: транспортные типы и доменные модели

Практическая рекомендация: держите как минимум два слоя типов:

  • Transport (DTO): структура данных, которую вы реально отправляете/получаете с сервера. Это то, что вы привязываете к API-контракту.
  • Domain (модель): ваши смысловые сущности и состояния, пригодные для бизнес-логики и UI.

Важно: доменная модель не должна “случайно” совпадать с DTO. Её смысл — быть удобной и безопасной для использования внутри приложения.

Минимальный каркас

code
// 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 — что используем внутри приложения

Пример: создание пользователя

code
// 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 возвращает либо “профиль активен”, либо “пользователь не активирован” с другой структурой.

code
// 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

code
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.

code
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}`);
}

Да, это не “нулевой” код, но это тот случай, когда рантайм-защита экономит время: лучше упасть контролируемо в момент маппинга, чем показать пользователю некорректное поведение.

Полный маппинг

code
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.

Пример: карточка пользователя

code
export type UserCardViewModel = {
  id: string;
  title: string;
  subtitle: string;
  badge: "Активен" | "Не активирован";
  reason?: string;
  onActionLabel: string;
};

Преобразование из домена в view model:

code
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, строк или произвольных объектов.

Практичный шаблон — унифицированный результат:

code
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 становится объединением:

code
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 — тоже расширяемое объединение:

code
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 можно расширить:

code
export type UserDtoV1 = {
  id: string;
  email: string;
  plan: "free" | "pro" | "enterprise";
};

export type UserDtoV2 = UserDtoV1 & {
  // новое поле, которое UI пока не использует
  marketingOptIn?: boolean;
};

Маппинг остаётся совместимым, а view model — прежней. Проблема только в обратном сценарии: когда вы начинаете требовать поле, которое может отсутствовать в старой версии ответа.

Здесь важно не моделировать “обязательное” поле как обязательное без гарантии.

Стратегия 3: runtime-совместимость через декодинг

Если вы ожидаете поле, которое может прийти в разных формах (например, plan может быть строкой или объектом), вы обязаны декодировать на рантайме.

Пример декодера:

code
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 формируется отдельно

Шаблон:

code
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,
});

Для сложных структур маппинг стоит писать через функции-декодеры:

code
export function decodeUserProfile(dto: UserProfileResponseDto): UserProfile {
  // switch по дискриминатору + parsePlan + parseDates и т.д.
  return mapUserProfileDtoToDomain(dto);
}

Главная цель — сделать невозможным “тихий” рассинхрон: если декодер не может преобразовать данные, вы сигнализируете об ошибке.


Типизируем UI: как избежать ветвлений по наличию полей

Ещё одна частая ошибка — UI проверяет опциональность:

code
if (profile.reason) { ... } // слабая проверка

Вместо этого используйте дискриминатор:

code
switch (profile.kind) {
  case "active": { ...; break; }
  case "inactive": { ...; break; }
  case "suspended": { ...; break; }
}

Тогда:

  • UI обязан обработать все варианты (или явно проигнорировать с оговоркой)
  • изменение доменной модели приводит к понятным compile-time ошибкам
  • меньше случайных undefined в рантайме

Ошибка дизайна: один тип для всего и “опциональности на максимум”

Иногда кажется удобным сделать один тип:

code
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).

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

TypeScript - для начинающих!

Курс по теме

TypeScript - для начинающих!

С нуля до уверенной работы с типами в JavaScript. Подходит для начинающих и тех, кто уже пишет на JS. Разберитесь с TypeScript раз и навсегда — и перестаньте ловить баги в рантайме.

Бесплатно
Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу

Приложения для iPhone и Apple Watch на SwiftUI

Разработка приложений для iPhone и Apple Watch на SwiftUI: навигация, SwiftData, виджеты, часы, выпуск. Нужен Mac с Xcode 27, сами устройства не нужны.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу

Приложения для macOS на SwiftUI

Разработка приложений для Mac на SwiftUI: окна и меню, Liquid Glass, SwiftData, сеть, выпуск. Нужен Mac с macOS 27 и Xcode 27.

Перейти к курсу

Другие статьи

TypeScript vs JavaScript: когда типы спасают проект, а когда мешают
typescript

TypeScript vs JavaScript: когда типы спасают проект, а когда мешают

Сравниваем подходы на реальных примерах кода, разбираем, при каких размерах и архитектурах проекта TypeScript окупается, а когда достаточно JSDoc.

16 июля 2026 г.
580
Generics в TypeScript: пишем универсальный код без потери типобезопасности
generics

Generics в TypeScript: пишем универсальный код без потери типобезопасности

Разбираем обобщённые типы на практике — от простых функций до сложных ограничений с extends и keyof. После статьи вы перестанете избегать дженериков и начнёте использовать их осознанно.

18 июля 2026 г.
560
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1141
Что такое переменная простыми словами: примеры из жизни и первый код
такое

Что такое переменная простыми словами: примеры из жизни и первый код

Разберём, что такое переменная без терминов: как “хранить” значение в памяти и как читать/менять его в программе. Дальше — мини-примеры на вводе/выводе и задания для новичка, чтобы закрепить понимание прямо в коде.

25 сентября 2026 г.
40
FastAPI и фоновые задачи: когда лучше использовать BackgroundTasks, а когда очередь
fastapi

FastAPI и фоновые задачи: когда лучше использовать BackgroundTasks, а когда очередь

Поймём различия между синхронной обработкой, BackgroundTasks и внешними очередями. Разберём idempotency, ретраи и мониторинг фоновых процессов.

22 июля 2026 г.
790
Какой первый проект выбрать новичку, чтобы не бросить обучение
первый

Какой первый проект выбрать новичку, чтобы не бросить обучение

Подберём 5–7 идей под уровень “с нуля”, объясним, что делать по шагам и как довести проект до результата без перегруза. В конце — как оформить мини-портфолио и что показать, даже если проект маленький.

23 сентября 2026 г.
240

Комментарии

Пока нет комментариев

Содержание

Типы для домена: где обычно возникает рассинхронАрхитектурная схема: транспортные типы и доменные моделиМинимальный каркасВход и выход API: типизируем контракты без размытияПример: создание пользователяДискриминируемые объединения: фиксируем состояния, а не “опциональные поля”Пример: загрузка сущности пользователемМаппинг из DTO в domainБезопасные маппинги: инварианты и запрет “магии as”Валидация enum-подобных значенийПолный маппингРазделяем UI-модель и доменную модельПример: карточка пользователяКонтракты ошибок: типизируем не только “успех”, но и сбоиМиграции без поломок рантайма: стратегии изменения API и схемСтратегия 1: версия контракта и адаптерыСтратегия 2: “мягкая” поддержка новых полей (backward/forward compatibility)Стратегия 3: runtime-совместимость через декодингТипизация маппингов: модель “вход — выход” и фабрикаТипизируем UI: как избежать ветвлений по наличию полейОшибка дизайна: один тип для всего и “опциональности на максимум”Практический рецепт: как внедрить подход в существующий проектШаг 1: зафиксируйте DTO и отделите их от доменаШаг 2: добавьте дискриминируемые состояния там, где есть ветвленияШаг 3: начните вводить декодеры для “опасных” полейШаг 4: стандартизируйте маппинги и ошибкиЧто делать с “генерацией типов” и схемамиИтоги: как TypeScript реально сокращает рассинхрон UI/API