TypeScript и типобезопасный UI/API: как строить схемы данных без ручного маппинга
Поговорим о подходе “types-first” и о том, как уменьшить количество ручных преобразований между слоями. Рассмотрим проверяемые границы: DTO, ответы сервера, формы и нормализацию данных.
Содержание
TypeScript и типобезопасный UI/API: как строить схемы данных без ручного маппинга
Современные фронтенд/бэкенд приложения почти всегда страдают от одной и той же боли: данные «живут» в разных формах на разных слоях. Сервер возвращает одно (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 — это подход, при котором вы сначала задаёте типы/контракт данных так, чтобы:
- UI и API использовали одну и ту же схему там, где это возможно.
- Отличия между слоями фиксировались осознанно и были «проверяемыми границами», а не набором ручных преобразований.
- Любые преобразования делались в узких местах (например, на границе API), а внутри приложения работали стабильные типы.
Цель не в том, чтобы полностью убрать маппинг. Цель — минимизировать поверхность, где схемы расходятся, и заставить TypeScript ловить несоответствия на этапе компиляции, а не во время отладки.
Проверяемые границы: как разделять слои, не ломая типы
Обычно разумно выделить четыре «точки»:
- DTO: формальная форма, в которой сервер ожидает/отдаёт данные.
- Ответ сервера: сериализованная структура (обычно близка к DTO).
- Форма: состояние ввода (частичное, часто со строковыми представлениями).
- Нормализация: структура данных для хранения в приложении (например, по
id).
Если делать types-first, то у каждого слоя появляется чёткий тип и понятные правила преобразований.
Базовое правило
- Там, где данные совпадают по семантике и форме — используйте один тип.
- Там, где форма отличается (например, UI требует удобного формата), — выделяйте преобразование в пограничную функцию и ограничивайте её типами.
DTO и контракты: фиксируем «схему правды»
Начнём с самого простого: описать DTO и выстроить типы так, чтобы они были источником истины.
Допустим, сервер отдаёт список задач:
- На сервере
priority— строка'low' | 'medium' | 'high' - На UI удобнее работать с
priorityLabelиpriorityValue(например, для сортировки и отображения)
Если вы начнёте делать это через маппер на каждый экран — вы быстро получаете ручные преобразования. Вместо этого стоит разделить:
- Семантический тип приоритета — общий для всех слоёв.
- Представление (label/value) — как вычисляемое/производное.
Пример: общий тип для семантики
// 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. Это уменьшает ручные преобразования до минимума.
Например:
// ui model может совпадать с DTO
import type { TaskDTO } from './domain';
type TaskCardProps = {
task: TaskDTO;
};
В реальных проектах так получается не всегда, но часто — да, если не пытаться «красиво» переименовать всё ещё на уровне DTO.
Ответ сервера и типизация сетевого слоя
Следующая граница — это то, как вы интерпретируете ответ сервера. Даже если TypeScript-полезен, он не проверяет данные во время выполнения. Если API вернёт неверную форму, компилятор не спасёт.
Поэтому types-first обычно дополняется проверкой на ранней стадии: хотя бы в одном месте — на границе API.
Выбор стратегии проверки
Есть три типичных сценария:
- Строго доверяем API (без валидации) — быстро, но риск на проде.
- Runtime-валидация (например, Zod/io-ts) — надёжно, но добавляет код.
- Смешанная стратегия: валидируем сложные структуры (массивы/объекты с ветвлениями), простые — доверяем.
Ниже пример со схемой валидации через Zod (как иллюстрация подхода). Если вы не хотите зависимости — можно использовать аналоги.
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>;
Теперь код получения данных становится «проверяемым»:
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 по чекбоксу. Форма хранит строки и частичные значения.
import type { Priority } from './domain';
export interface TaskFormInput {
title: string; // в форме строка
priority: Priority | ''; // пустое значение пока пользователь не выбрал
completed: boolean; // часто сразу boolean
}
Если вы используете контролируемые инпуты, это обычно подходит напрямую.
Граница «форма → DTO»: не маппинг, а сборка с проверкой
Здесь маппинг неизбежен: вы должны преобразовать '' в валидный Priority или запретить отправку.
Но можно сделать преобразование точным по типам и централизованным:
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-данные из формы в одном месте.
Автономные типы ошибок и валидаторов
Чтобы уменьшить количество преобразований ещё сильнее, можно не бросать исключения, а формализовать результат:
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[] с сервера, вам нужно нормализовать. Но это не «ручной маппинг бизнес-смысла». Это структурирование для хранения.
Нормализация как отдельная функция, но с типовой связкой
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-список» из нормализованного хранилища вручную на каждом месте, вынесите селектор:
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) без изменения базовой структуры
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— хранение
Пример связки:
// 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'>;
}
Пока вы придерживаетесь этих правил, «ручной маппинг»
Комментарии
Пока нет комментариев