$ 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
·25 июля 2026 г.·10 мин·53
TypeScript и типобезопасный UI/API: как строить схемы данных без ручного маппинга

Поговорим о подходе “types-first” и о том, как уменьшить количество ручных преобразований между слоями. Рассмотрим проверяемые границы: DTO, ответы сервера, формы и нормализацию данных.

Содержание
Проблема ручного маппинга: почему он «разъезжается»1) Дублирование структуры2) Дрейф типов3) Локальные компромиссы4) Сложность тестированияTypes-first: идея и практическая цельПроверяемые границы: как разделять слои, не ломая типыБазовое правилоDTO и контракты: фиксируем «схему правды»Пример: общий тип для семантикиКогда DTO и UI могут совпадатьОтвет сервера и типизация сетевого слояВыбор стратегии проверкиФормы: где типы почти всегда отличаютсяTypes-first для формы: описываем «ввод», а не «идеал»Граница «форма → DTO»: не маппинг, а сборка с проверкойАвтономные типы ошибок и валидаторовНормализация: избавляемся от «копий данных» между компонентамиНормализация как отдельная функция, но с типовой связкойДальше — только селекторы, а не повторная сборкаСокращаем ручной маппинг: практики, которые действительно работают1) Не переизобретайте доменные типы в UI2) Делайте производные свойства как вычисления, а не как «новые сущности»3) Централизуйте преобразования на границах4) Используйте “подтипы”, а не новые «полные модели»5) Откажитесь от any в «стыках»Пример архитектуры по слоям: минимальная карта типов

Современные фронтенд/бэкенд приложения почти всегда страдают от одной и той же боли: данные «живут» в разных формах на разных слоях. Сервер возвращает одно (DTO), UI ожидает другое (модель экрана), формы и компоненты — третье (shape под валидацию и UX), а бизнес-логика требует четвёртого (нормализованные структуры, пригодные для редьюсеров, кешей и т. п.).

Если делать всё «вручную», то между слоями быстро накапливаются мапперы: dtoToUi, uiToPayload, responseToForm, formToCommand и т. д. Они становятся источником дрейфа схем: сегодня поля совпали, завтра поменяли тип, послезавтра добавили новое значение — и где-то один маппер обновили, а другой забыли.

В этой статье разберём подход types-first: как строить схемы данных и закреплять типы так, чтобы ручных преобразований между слоями становилось меньше, а проверяемые границы росли. Поговорим о практических «контрактах» между слоями: DTO, ответы сервера, формы и нормализацию данных. В конце — честно о компромиссах и о том, где маппинг всё равно неизбежен.


Проблема ручного маппинга: почему он «разъезжается»

Ручные преобразования создают несколько классов проблем:

1) Дублирование структуры

Часто это выглядит так: DTO и UI-модель «почти одинаковые», но отличаются именами полей, структурой вложенности или форматами (например, дата как строка vs Date, id как number vs string, status как строка vs enum).

Пока совпадение почти полное — всё работает. Но постепенно UI начинает «требовать» удобств: например, для компонентов нужен isActive, но сервер возвращает status. Вы добавляете вычисление — и оно постепенно превращается в слой маппинга.

2) Дрейф типов

TypeScript помогает, пока вы соединяете слои через типы. Но ручной маппер — это просто код. Он компилируется даже если вы забыли обновить соответствие полей, когда типы «сходятся случайно» (например, через any, шире тип unknown, или из-за слишком общих типов).

3) Локальные компромиссы

Формы часто тянут типы «в сторону», потому что UX требует частичных значений (незаполненные поля), строковые представления чисел, массивы как string[] для удобной работы с инпутами и т. д.

Если UI-форма и API-пейлоад связаны вручную, появляется риск, что вы отправите данные в неверном формате.

4) Сложность тестирования

Мапперы требуют тестов не потому, что они «плохие», а потому что они по сути преобразуют бизнес-данные. Это дополнительная работа.


Types-first: идея и практическая цель

Types-first — это подход, при котором вы сначала задаёте типы/контракт данных так, чтобы:

  1. UI и API использовали одну и ту же схему там, где это возможно.
  2. Отличия между слоями фиксировались осознанно и были «проверяемыми границами», а не набором ручных преобразований.
  3. Любые преобразования делались в узких местах (например, на границе API), а внутри приложения работали стабильные типы.

Цель не в том, чтобы полностью убрать маппинг. Цель — минимизировать поверхность, где схемы расходятся, и заставить TypeScript ловить несоответствия на этапе компиляции, а не во время отладки.


Проверяемые границы: как разделять слои, не ломая типы

Обычно разумно выделить четыре «точки»:

  1. DTO: формальная форма, в которой сервер ожидает/отдаёт данные.
  2. Ответ сервера: сериализованная структура (обычно близка к DTO).
  3. Форма: состояние ввода (частичное, часто со строковыми представлениями).
  4. Нормализация: структура данных для хранения в приложении (например, по id).

Если делать types-first, то у каждого слоя появляется чёткий тип и понятные правила преобразований.

Базовое правило

  • Там, где данные совпадают по семантике и форме — используйте один тип.
  • Там, где форма отличается (например, UI требует удобного формата), — выделяйте преобразование в пограничную функцию и ограничивайте её типами.

DTO и контракты: фиксируем «схему правды»

Начнём с самого простого: описать DTO и выстроить типы так, чтобы они были источником истины.

Допустим, сервер отдаёт список задач:

  • На сервере priority — строка 'low' | 'medium' | 'high'
  • На UI удобнее работать с priorityLabel и priorityValue (например, для сортировки и отображения)

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

  • Семантический тип приоритета — общий для всех слоёв.
  • Представление (label/value) — как вычисляемое/производное.

Пример: общий тип для семантики

code
// domain.ts
export type Priority = 'low' | 'medium' | 'high';

export interface TaskDTO {
  id: string;
  title: string;
  priority: Priority;
  completed: boolean;
}

На этом этапе DTO уже выглядит как хороший доменный тип — «похожий на UI». Отличия могут появиться позже, но ключевое: вы не дублируете семантику.

Когда DTO и UI могут совпадать

Если UI практически напрямую отображает поля TaskDTO, вы можете использовать этот тип прямо в UI. Это уменьшает ручные преобразования до минимума.

Например:

code
// ui model может совпадать с DTO
import type { TaskDTO } from './domain';

type TaskCardProps = {
  task: TaskDTO;
};

В реальных проектах так получается не всегда, но часто — да, если не пытаться «красиво» переименовать всё ещё на уровне DTO.


Ответ сервера и типизация сетевого слоя

Следующая граница — это то, как вы интерпретируете ответ сервера. Даже если TypeScript-полезен, он не проверяет данные во время выполнения. Если API вернёт неверную форму, компилятор не спасёт.

Поэтому types-first обычно дополняется проверкой на ранней стадии: хотя бы в одном месте — на границе API.

Выбор стратегии проверки

Есть три типичных сценария:

  1. Строго доверяем API (без валидации) — быстро, но риск на проде.
  2. Runtime-валидация (например, Zod/io-ts) — надёжно, но добавляет код.
  3. Смешанная стратегия: валидируем сложные структуры (массивы/объекты с ветвлениями), простые — доверяем.

Ниже пример со схемой валидации через Zod (как иллюстрация подхода). Если вы не хотите зависимости — можно использовать аналоги.

code
import { z } from 'zod';
import type { TaskDTO, Priority } from './domain';

const PrioritySchema: z.ZodType<Priority> = z.union([
  z.literal('low'),
  z.literal('medium'),
  z.literal('high'),
]);

export const TaskDTOSchema: z.ZodType<TaskDTO> = z.object({
  id: z.string(),
  title: z.string(),
  priority: PrioritySchema,
  completed: z.boolean(),
});

export const TasksResponseSchema = z.object({
  tasks: z.array(TaskDTOSchema),
});

// тип ответа выводится из схемы:
export type TasksResponse = z.infer<typeof TasksResponseSchema>;

Теперь код получения данных становится «проверяемым»:

code
async function fetchTasks(): Promise<TasksResponse> {
  const res = await fetch('/api/tasks');
  const json = await res.json();
  return TasksResponseSchema.parse(json); // падает на несовпадении схем
}

Ключ: у вас есть один тип TaskDTO, и на границе выполняется проверка. Внутри приложения не нужно заново «перепридумывать» типы.


Формы: где типы почти всегда отличаются

Форма почти всегда отличается от DTO по смыслу и структуре:

  • в форме поля могут быть пустыми ('') или отсутствовать (undefined)
  • числа в инпутах приходят как строки
  • валидация опирается на UX (сообщения об ошибках, touched/dirty)
  • форма допускает частичность при редактировании

Поэтому полностью отказаться от отдельного типа формы сложно и не нужно. Но можно уменьшить маппинг и сделать его безопасным.

Types-first для формы: описываем «ввод», а не «идеал»

Допустим, пользователь редактирует задачу: title и priority, completed по чекбоксу. Форма хранит строки и частичные значения.

code
import type { Priority } from './domain';

export interface TaskFormInput {
  title: string;          // в форме строка
  priority: Priority | ''; // пустое значение пока пользователь не выбрал
  completed: boolean;     // часто сразу boolean
}

Если вы используете контролируемые инпуты, это обычно подходит напрямую.

Граница «форма → DTO»: не маппинг, а сборка с проверкой

Здесь маппинг неизбежен: вы должны преобразовать '' в валидный Priority или запретить отправку.

Но можно сделать преобразование точным по типам и централизованным:

code
import type { Priority, TaskDTO } from './domain';
import type { TaskFormInput } from './form';

function normalizePriority(value: TaskFormInput['priority']): Priority {
  if (value === '') throw new Error('Priority is required');
  return value;
}

export function formInputToTaskPatch(
  input: TaskFormInput,
  taskId: string
): Pick<TaskDTO, 'title' | 'priority' | 'completed'> {
  return {
    title: input.title.trim(),
    priority: normalizePriority(input.priority),
    completed: input.completed,
  };
}

Важно: функция возвращает DTO-подмножество, то есть вы не строите UI-модель и не дублируете поля. Вы собираете API-данные из формы в одном месте.

Автономные типы ошибок и валидаторов

Чтобы уменьшить количество преобразований ещё сильнее, можно не бросать исключения, а формализовать результат:

code
type Result<T> = { ok: true; value: T } | { ok: false; error: string };

function normalizePrioritySafe(value: TaskFormInput['priority']): Result<Priority> {
  if (value === '') return { ok: false, error: 'Priority is required' };
  return { ok: true, value };
}

Тогда компонент формы не ловит исключения, а получает структурированный результат. Это делает границу «форма → DTO» предсказуемой и тестируемой.


Нормализация: избавляемся от «копий данных» между компонентами

Следующий источник маппинга — это структура хранения данных в состоянии. UI часто любит списки, но приложения чаще удобнее строить на нормализованных структурах:

  • byId — объект соответствий
  • allIds — массив идентификаторов
  • обновления через редьюсеры проще и дешевле

Если вы получаете массив TaskDTO[] с сервера, вам нужно нормализовать. Но это не «ручной маппинг бизнес-смысла». Это структурирование для хранения.

Нормализация как отдельная функция, но с типовой связкой

code
import type { TaskDTO } from './domain';

export type TasksById = Record<string, TaskDTO>;
export interface NormalizedTasks {
  byId: TasksById;
  allIds: string[];
}

export function normalizeTasks(tasks: TaskDTO[]): NormalizedTasks {
  const byId: TasksById = {};
  const allIds: string[] = [];

  for (const t of tasks) {
    byId[t.id] = t;
    allIds.push(t.id);
  }

  return { byId, allIds };
}

Здесь нет «перевода полей» — только организация по id. Типы остаются идентичными TaskDTO, что резко снижает риск несовпадений.

Дальше — только селекторы, а не повторная сборка

Вместо того чтобы собирать «UI-список» из нормализованного хранилища вручную на каждом месте, вынесите селектор:

code
import type { NormalizedTasks } from './normalize';

export function selectTaskIds(state: NormalizedTasks): string[] {
  return state.allIds;
}

export function selectTaskById(state: NormalizedTasks, id: string) {
  return state.byId[id];
}

Компоненты получают стабильные типы.


Сокращаем ручной маппинг: практики, которые действительно работают

Ниже несколько подходов, которые в реальных проектах дают максимальный эффект.

1) Не переизобретайте доменные типы в UI

Если DTO и доменная модель совпадают — используйте DTO (или вынесите доменные типы в общий модуль и переиспользуйте).

Переименование «ради красоты» почти всегда приводит к мапперам.

2) Делайте производные свойства как вычисления, а не как «новые сущности»

Если UI хочет priorityLabel, не создавайте тип TaskUI с этим полем. Вместо этого:

  • либо создайте функцию getPriorityLabel(priority)
  • либо мапьте в презентации (в render/selector) без изменения базовой структуры
code
import type { Priority } from './domain';

export function getPriorityLabel(p: Priority): string {
  switch (p) {
    case 'low': return 'Низкий';
    case 'medium': return 'Средний';
    case 'high': return 'Высокий';
  }
}

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

3) Централизуйте преобразования на границах

Граница API — отличное место для dto -> domain (если вы разделяете их). Граница формы — отличное место для form -> dto.

Внутри приложения старайтесь работать с одним «якорным» типом данных.

4) Используйте “подтипы”, а не новые «полные модели»

Когда нужно отправить запрос на обновление — возвращайте Pick/Omit/конкретные типы под задачу:

  • меньше дублирования
  • меньше шансов забыть поле
  • типы проще поддерживать

5) Откажитесь от any в «стыках»

Самая частая причина появления ручного маппинга — это когда типы на границе слишком слабые (например, unknown без проверки, или any после парсинга).

Если вы всё равно валидируете данные runtime — выводите типы из схемы. Если не валидируете — хотя бы держите узкую область as и минимизируйте её.


Пример архитектуры по слоям: минимальная карта типов

Чтобы сделать идею наглядной, соберём «карту» для задачи приложения:

  • TaskDTO — якорный доменный контракт
  • TasksResponse — ответ сервера (проверяем runtime)
  • TaskFormInput — ввод пользователя
  • NormalizedTasks — хранение

Пример связки:

code
// domain.ts
export type Priority = 'low' | 'medium' | 'high';

export interface TaskDTO {
  id: string;
  title: string;
  priority: Priority;
  completed: boolean;
}

// api.ts
import type { TaskDTO } from './domain';
import { z } from 'zod';

const PrioritySchema = z.union([z.literal('low'), z.literal('medium'), z.literal('high')]);
const TaskDTOSchema = z.object({
  id: z.string(),
  title: z.string(),
  priority: PrioritySchema,
  completed: z.boolean(),
});

export const TasksResponseSchema = z.object({
  tasks: z.array(TaskDTOSchema),
});

export type TasksResponse = z.infer<typeof TasksResponseSchema>;

export async function fetchTasks(): Promise<TasksResponse> {
  const res = await fetch('/api/tasks');
  const json = await res.json();
  return TasksResponseSchema.parse(json);
}

// normalize.ts
import type { TaskDTO } from './domain';

export type TasksById = Record<string, TaskDTO>;
export interface NormalizedTasks {
  byId: TasksById;
  allIds: string[];
}

export function normalizeTasks(tasks: TaskDTO[]): NormalizedTasks {
  const byId: TasksById = {};
  const allIds: string[] = [];
  for (const t of tasks) {
    byId[t.id] = t;
    allIds.push(t.id);
  }
  return { byId, allIds };
}

// form.ts
import type { Priority, TaskDTO } from './domain';

export interface TaskFormInput {
  title: string;
  priority: Priority | '';
  completed: boolean;
}

function normalizePriority(value: TaskFormInput['priority']): Priority {
  if (value === '') throw new Error('Priority is required');
  return value;
}

export function formInputToTaskPatch(input: TaskFormInput, taskId: string) {
  return {
    id: taskId,
    title: input.title.trim(),
    priority: normalizePriority(input.priority),
    completed: input.completed,
  } as Pick<TaskDTO, 'id' | 'title' | 'priority' | 'completed'>;
}

Пока вы придерживаетесь этих правил, «ручной маппинг»

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

Автор

$ sudo teach IT

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

Курс по теме

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

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

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

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

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

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

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

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

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

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

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

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

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

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

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость
python

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

Поймём, где чаще всего ломается воспроизводимость (версии данных, фильтры, дрейф) и как оформить пайплайн признаков так, чтобы результаты совпадали между запусками и окружениями.

17 сентября 2026 г.
1050
Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API
модели

Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API

Разберём, как описывать доменные сущности, DTO и error-model на уровне типов, чтобы ловить несовпадения схем до продакшена. Покажем практические шаблоны для типобезопасного маппинга и версионирования контрактов.

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

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

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1090
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

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

Содержание

Проблема ручного маппинга: почему он «разъезжается»1) Дублирование структуры2) Дрейф типов3) Локальные компромиссы4) Сложность тестированияTypes-first: идея и практическая цельПроверяемые границы: как разделять слои, не ломая типыБазовое правилоDTO и контракты: фиксируем «схему правды»Пример: общий тип для семантикиКогда DTO и UI могут совпадатьОтвет сервера и типизация сетевого слояВыбор стратегии проверкиФормы: где типы почти всегда отличаютсяTypes-first для формы: описываем «ввод», а не «идеал»Граница «форма → DTO»: не маппинг, а сборка с проверкойАвтономные типы ошибок и валидаторовНормализация: избавляемся от «копий данных» между компонентамиНормализация как отдельная функция, но с типовой связкойДальше — только селекторы, а не повторная сборкаСокращаем ручной маппинг: практики, которые действительно работают1) Не переизобретайте доменные типы в UI2) Делайте производные свойства как вычисления, а не как «новые сущности»3) Централизуйте преобразования на границах4) Используйте “подтипы”, а не новые «полные модели»5) Откажитесь от any в «стыках»Пример архитектуры по слоям: минимальная карта типов