$ 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 мин·76
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 – для начинающих!

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

Перейти к курсу
Solidity DLC - поймай, если сможешь

Solidity DLC - поймай, если сможешь

Аудит смарт-контрактов на реальных взломах. The DAO, Parity, Wormhole, Ronin, Euler и ещё 7 разборов: что пошло не так, сколько потеряли и что бы спасло. Атаки и защиты на Foundry, план входа в профессию аудитора.

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

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

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

Перейти к курсу
Solidity pro - всё или ничего

Solidity pro - всё или ничего

Как пишут контракты, которые работают в основной сети. Наследование и OpenZeppelin, паттерны продакшена, оптимизация газа, ABI, delegatecall и подписи. 7 модулей, 33 урока, а в финале собственное dApp: мини-DAO с токеном, голосованием и казной.

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

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

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

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

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

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

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

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

15 сентября 2026 г.
1270
Как выучить программирование самому и бесплатно: с чего начать и в каком порядке
выучить

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

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

21 сентября 2026 г.
1960
Как начать с HTML и сделать первую страницу: пошагово и без сложных слов
html

Как начать с HTML и сделать первую страницу: пошагово и без сложных слов

Разберём, что нужно новичку, чтобы за 1–2 вечера собрать свою первую веб-страницу: структура HTML, базовые теги, ссылки и картинки, плюс простой чек-лист, как понять, что страница работает правильно.

2 ноября 2026 г.
20
Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей
cargo

Cargo для продакшена: workspaces, фичи и стратегия версий зависимостей

Разберём, как организовать Rust workspace, управлять optional features, фиксировать версии библиотек и уменьшать “дрейф” зависимостей при командной разработке и CI.

18 сентября 2026 г.
1960
Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах
такое

Что такое HTML и чем он отличается от CSS — объясню простыми словами и на примерах

Разберём, из чего состоит веб-страница: что делает HTML (смысл и структура) и что делает CSS (оформление). В конце соберёте мини-страницу и поймёте, что нужно учить в каком порядке.

30 октября 2026 г.
130

Комментарии

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

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

Содержание

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