Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API
Разберём, как описывать доменные сущности, DTO и error-model на уровне типов, чтобы ловить несовпадения схем до продакшена. Покажем практические шаблоны для типобезопасного маппинга и версионирования контрактов.
Содержание
Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API
В проектах с фронтендом на TypeScript и API на любом языке “контракт” почти всегда существует — но редко описан как контракт на уровне типов. Он живёт в виде документации, примеров запросов в Swagger, ожиданий команды и наборов “проверок на всякий случай”. В итоге расхождения схем всплывают поздно: в лучшем случае на QA, в худшем — в продакшене.
Хорошая новость: TypeScript позволяет описывать доменные сущности, DTO и модели ошибок так, чтобы несовпадения схем ловились до исполнения и хотя бы частично — до деплоя. В этой статье разберём практические подходы: как построить типобезопасные модели, как маппить данные между слоями, как версионировать контракты и как проектировать error-model так, чтобы фронт корректно реагировал на проблемы.
Будем говорить не про “набор интерфейсов ради интерфейсов”, а про проектирование контрактов как части архитектуры.
Почему “просто интерфейс” не спасает
Типизация в TypeScript обычно стартует с чего-то вроде:
type UserDto = {
id: string;
email: string;
};
и дальше:
async function getUser(): Promise<UserDto> {
const res = await fetch("/api/user/123");
return res.json();
}
Проблемы начинаются сразу:
- Тип “обещает”, но не проверяет.
res.json()всегда имеет типany. Компилятор лишь доверяет разработчику. - Контракт дрейфует. Backend меняет поле
emailнаlogin, а фронт продолжает компилироваться, потому что никаких проверок реальности нет. - Смешиваются домен и транспорт. На фронте часто хочется “человеческие” модели, но API отдаёт DTO. Если их не разделять, типы становятся хаотичными.
- Ошибка — отдельный контракт. Ошибки обычно описаны “как попало”: где-то приходит
{message: "..."}, где-то{code: "...", details: ...}. Фронт начинает делатьif (typeof err === ...), а это хрупко.
Чтобы бороться с этим, нужно рассматривать типы как модель контракта, включая версионирование и строгие преобразования.
Архитектура типов: домен, DTO и отображение
Хорошая практика — разделить три уровня:
- Домен — модели приложения: что это за сущность и как она используется.
- DTO — транспортные формы, близкие к JSON API.
- Отображение (mapping) — функции, которые преобразуют DTO → домен и домен → DTO (если отправляете данные обратно).
Так вы:
- делаете контракт “явным” на границе,
- держите доменные инварианты в домене,
- а маппинг превращает “сырой JSON” в предсказуемые модели.
Рассмотрим пример: “Заказ” в домене и “OrderDto” на API.
Доменные модели и инварианты
Домен — не просто набор полей. Это место, где обычно живут инварианты и нормализация. Допустим:
totalв домене всегда положительныйnumber;status— строго из конечного набора.
// Domain
export type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
export type Order = {
id: string;
customerId: string;
itemsCount: number;
total: number; // всегда >= 0 в домене
status: OrderStatus;
};
Инварианты (например total >= 0) можно проверять при маппинге.
DTO как транспортная правда
DTO может отличаться:
- API отдаёт
totalстрокой (как"199.90"); - статус — в другом регистре:
"PAID"; itemsCountAPI может вообще не отдавать — нужно вывести изitems.
// DTO
export type OrderDto = {
id: string;
customer_id: string;
total_amount: string; // "199.90"
status: "PENDING" | "PAID" | "SHIPPED" | "CANCELLED";
items: Array<{ sku: string; qty: number }>;
};
Важно: DTO отражает реальную схему API. Домен — отражает удобство и инварианты приложения.
Типобезопасный маппинг с валидацией
В TypeScript тип сам по себе не валидирует данные. Поэтому маппинг должен либо:
- использовать runtime-валидацию,
- либо опираться на генераторы типов из OpenAPI (где валидация/парсинг можно подтянуть),
- либо хотя бы централизованно ловить несоответствия.
Практичный компромисс: использовать runtime-валидацию через библиотеку вроде zod (или io-ts). Ниже пример с zod.
Вариант: DTO-схема через zod и маппинг в домен
import { z } from "zod";
const OrderDtoSchema = z.object({
id: z.string(),
customer_id: z.string(),
total_amount: z.string(), // парсим в domain
status: z.enum(["PENDING", "PAID", "SHIPPED", "CANCELLED"]),
items: z.array(
z.object({
sku: z.string(),
qty: z.number().int().nonnegative(),
})
),
});
type OrderDto = z.infer<typeof OrderDtoSchema>;
const statusMap: Record<OrderDto["status"], OrderStatus> = {
PENDING: "pending",
PAID: "paid",
SHIPPED: "shipped",
CANCELLED: "cancelled",
};
function parseMoneyToNumber(amount: string): number {
// Пример: "199.90" -> 199.9
const n = Number(amount);
if (!Number.isFinite(n)) throw new Error(`Invalid money amount: ${amount}`);
return n;
}
export function mapOrderDtoToDomain(dto: OrderDto): Order {
const total = parseMoneyToNumber(dto.total_amount);
if (total < 0) throw new Error(`Order total must be non-negative`);
const itemsCount = dto.items.reduce((acc, it) => acc + it.qty, 0);
return {
id: dto.id,
customerId: dto.customer_id,
itemsCount,
total,
status: statusMap[dto.status],
};
}
Теперь fetch → json → validate → map. Несовпадение схем ловится до того, как данные станут частью домена.
Централизация: маппинг должен быть единым источником правды
Чтобы не размазывать валидацию по проекту, полезно сделать единый “адаптер” для API-ответов:
async function getJsonValidated<T>(
url: string,
schema: z.ZodSchema<T>,
init?: RequestInit
): Promise<T> {
const res = await fetch(url, init);
const json = await res.json();
return schema.parse(json);
}
Использование:
export async function fetchOrder(orderId: string) {
const dto = await getJsonValidated(
`/api/v1/orders/${orderId}`,
OrderDtoSchema
);
return mapOrderDtoToDomain(dto);
}
Так вы гарантируете: домен не получит “грязный” JSON.
Контракт “запрос/ответ”: типизация на уровне endpoints
Дальше логично “упаковать” контракты по endpoint’ам. Это уменьшает риск несоответствий между руками написанными функциями и схемами.
Унифицированная сигнатура endpoint’а
Можно описывать endpoint как “что отправляем” и “что получаем”. Например:
GET /orders/:idвозвращаетOrderDto,- ошибки возвращают
ErrorModel.
type ApiErrorResponse = {
error: {
code: string;
message: string;
requestId?: string;
details?: unknown;
};
};
Но тут важен следующий момент: ошибка — тоже часть контракта и должна быть типизирована более строго, чем code: string.
Мы к этому вернёмся ниже.
Типы для response union’ов: успех vs ошибка
Одно из удобных решений — описать результат как discriminated union:
type ApiOk<T> = { ok: true; value: T };
type ApiFail<E> = { ok: false; error: E };
type ApiResult<T, E> = ApiOk<T> | ApiFail<E>;
И endpoint типизируется как Promise<ApiResult<...>>. Например:
type GetOrderResponse = ApiResult<Order, ApiError>;
Но тогда где описать ApiError? Нужна error-model.
Error-model: типы, которые выдерживают реальные кейсы
В реальных API есть сложности:
- 401/403 обычно приходят иначе, чем 400/422,
- 404 может приходить с
{error: {code: "NOT_FOUND"}}, - 409 — с конфликтом и деталями,
- валидационные ошибки (422) часто содержат поле-level ошибки.
Если вы делаете “везде одинаковый error: {message}”, вы потеряете возможность правильно обрабатывать ошибки на фронте.
Проектирование error-model как набора вариантов
Рассмотрим кейсы:
NOT_FOUND— ресурс отсутствует,UNAUTHORIZED— токен/сессия невалидна,VALIDATION_ERROR— ошибки полей,CONFLICT— бизнес-конфликт (например, заказ уже оплачен).
type ApiErrorBase = {
requestId?: string;
message: string;
};
type NotFoundError = ApiErrorBase & {
code: "NOT_FOUND";
entity?: string;
id?: string;
};
type UnauthorizedError = ApiErrorBase & {
code: "UNAUTHORIZED";
};
type ValidationError = ApiErrorBase & {
code: "VALIDATION_ERROR";
fieldErrors: Record<string, string>; // "email": "invalid format"
};
type ConflictError = ApiErrorBase & {
code: "CONFLICT";
conflictWith?: string;
};
type ApiError = NotFoundError | UnauthorizedError | ValidationError | ConflictError;
runtime-парсер error-model
Типы снова требуют runtime-валидации. Пример со zod:
import { z } from "zod";
const ErrorBaseSchema = z.object({
code: z.string(),
message: z.string(),
requestId: z.string().optional(),
});
const NotFoundErrorSchema = ErrorBaseSchema.extend({
code: z.literal("NOT_FOUND"),
entity: z.string().optional(),
id: z.string().optional(),
});
const UnauthorizedErrorSchema = ErrorBaseSchema.extend({
code: z.literal("UNAUTHORIZED"),
});
const ValidationErrorSchema = ErrorBaseSchema.extend({
code: z.literal("VALIDATION_ERROR"),
fieldErrors: z.record(z.string()), // value: string
});
const ConflictErrorSchema = ErrorBaseSchema.extend({
code: z.literal("CONFLICT"),
conflictWith: z.string().optional(),
});
const ApiErrorSchema = z.discriminatedUnion("code", [
NotFoundErrorSchema,
UnauthorizedErrorSchema,
ValidationErrorSchema,
ConflictErrorSchema,
]);
type ApiErrorDto = z.infer<typeof ApiErrorSchema>;
const ApiErrorResponseSchema = z.object({
error: ApiErrorSchema,
});
Парсер ошибки:
async function parseApiError(res: Response): Promise<ApiError> {
const json = await res.json();
const parsed = ApiErrorResponseSchema.parse(json);
return parsed.error;
}
Теперь вы можете гарантировать: ApiError.code — конечный набор, а фронт может делать switch без “ветвлений по наитию”.
Ловим несовпадения до продакшена
Если backend внезапно начинает возвращать code: "NOT_FOUND_WRONG" — парсер упадёт. Это может быть плохо, если не предусмотрена стратегия. Но это как раз то, что хорошо: вы обнаруживаете рассинхрон схем. Дальше — решение:
- либо логировать и показывать “неизвестная ошибка”,
- либо падать в development,
- либо поддерживать fallback schema (но это снижает ценность типизации).
Например, fallback:
const UnknownErrorSchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
requestId: z.string().optional(),
details: z.any().optional(),
}),
});
type UnknownApiError = {
code: string;
message: string;
requestId?: string;
details?: unknown;
};
async function safeParseApiError(res: Response): Promise<ApiError | UnknownApiError> {
const json = await res.json();
try {
return ApiErrorResponseSchema.parse(json).error;
} catch {
const parsed = UnknownErrorSchema.parse(json);
return parsed.error;
}
}
Типобезопасный fetch слой: маппинг HTTP в домен
Типизация контрактов становится действительно полезной только тогда, когда она закреплена в вашем HTTP-слое.
Унифицированная функция запроса
Идея:
- вы передаёте endpoint-конфигурацию,
- указываете schema для success DTO,
- и schema для error-model,
- функция возвращает
ApiResult<Domain, ApiError>.
Пример:
import { z } from "zod";
type ApiOk<T> = { ok: true; value: T };
type ApiFail<E> = { ok: false; error: E };
type ApiResult<T, E> = ApiOk<T> | ApiFail<E>;
async function requestWithMapping<TDto, TDomain>(args: {
url: string;
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
dtoSchema: z.ZodSchema<TDto>;
mapDtoToDomain: (dto: TDto) => TDomain;
errorParser: (res: Response) => Promise<any>;
init?: RequestInit;
}): Promise<ApiResult<TDomain, any>> {
const { url, method = "GET", dtoSchema, mapDtoToDomain, init, errorParser } = args;
const res = await fetch(url, { method, ...init });
if (!res.ok) {
const error = await errorParser(res);
return { ok: false, error };
}
const json = await res.json();
const dto = dtoSchema.parse(json);
return { ok: true, value: mapDtoToDomain(dto) };
}
Использование для Order:
export async function fetchOrder(orderId: string) {
return requestWithMapping<OrderDto, Order>({
url: `/api/v1/orders/${orderId}`,
dtoSchema: OrderDtoSchema,
mapDtoToDomain: mapOrderDtoToDomain,
errorParser: safeParseApiError,
});
}
Теперь контракт стал “программируемым”: success и error проходят через схемы и маппинг.
Версионирование контрактов: не ломать фронт каждый релиз
Версионирование — это зона, где чаще всего “контракт в документации” выглядит как декларация, а не как механизм.
Два уровня версий
Обычно есть две разные стратегии:
- Версия API в URL (
/api/v1/...). - Версия схемы внутри payload (например,
schemaVersionв данных).
Если вы не можете управлять URL (например, API уже живёт), иногда схема в payload — единственный рычаг.
Вариант: версия DTO как отдельный тип
Допустим, в v1 статус приходит “нижним регистром”, а в v2 — “верхним”. Тогда не пытайтесь сделать один OrderDto на все версии: вы потеряете корректность.
Создайте разные DTO и разные схемы:
// v1 DTO
type OrderDtoV1 = {
id: string;
customer_id: string;
total_amount: string;
status: "pending" | "paid" | "shipped" | "cancelled";
items: Array<{ sku: string; qty: number }>;
};
// v2 DTO
type OrderDtoV2 = {
id: string;
customer_id: string;
total_amount: string;
status: "PENDING" | "PAID" | "SHIPPED" | "CANCELLED";
items: Array<{ sku: string; qty: number }>;
currency: "EUR" | "USD";
};
Маппинг в один и тот же домен:
const statusMapV1 = {
pending: "pending",
paid: "paid",
shipped: "shipped",
cancelled: "cancelled",
} as const;
const statusMapV2 = {
PENDING: "pending",
PAID: "paid",
SHIPPED: "shipped",
CANCELLED: "cancelled",
} as const;
Endpoint как “ключ контракта”
Вы можете оформить это как типизированный endpoint-ключ. Например:
type ApiVersion = "v1" | "v2";
type GetOrderEndpoint = {
version: ApiVersion;
orderId: string;
};
А дальше — выбор нужного парсера по версии:
export async function fetchOrderByVersion(args: GetOrderEndpoint) {
if (args.version === "v1") {
const res = await requestWithMapping<OrderDtoV1, Order>({
url: `/api/v1/orders/${args.orderId}`,
dtoSchema: OrderDtoV1Schema,
mapDtoToDomain: mapOrderDtoV1ToDomain,
errorParser: safeParseApiError,
});
return res;
}
const res = await requestWithMapping<OrderDtoV2, Order>({
url: `/api/v2/orders/${args.orderId}`,
dtoSchema: OrderDtoV2Schema,
mapDtoToDomain: mapOrderDtoV2ToDomain,
errorParser: safeParseApiError,
});
return res;
}
Так вы сохраняете совместимость и не “перемешиваете” версии в одном типе.
Встроенное тестирование контрактов
Чтобы несовпадения ловились раньше, полезно добавить контрактные тесты (контракт → схема → маппинг). Даже простой smoke-test на примерах payload поможет:
- если API поменял поле — тест падает,
- если формат статуса поменялся — тест падает,
- если ошибка стала другой — тест падает.
На практике это часто делается через “fixtures” и прогон валидатора.
DTO ↔ домен: шаблоны маппинга, которые не развалятся
Одна из ошибок — писать “ручные” преобразования полей прямо в компонентах. Лучше выделить слой адаптеров.
Шаблон 1: чистые функции mapXxx
export function mapMoneyStringToNumber(s: string): number {
const n = Number(s);
if (!Number.isFinite(n)) throw new Error(`Invalid money: ${s}`);
return n;
}
И затем использовать это в нескольких маппингах.
Шаблон 2: mapping “съедает” различия DTO
- парсинг дат,
- преобразование регистров,
- переименование snake_case → camelCase,
- вычисление производных полей,
- нормализация пустых значений.
Пример даты:
function parseIsoDateToMs(date: string): number {
const t = Date.parse(date);
if (!Number.isFinite(t)) throw new Error(`Invalid date: ${date}`);
return t;
}
И дальше домен использует number (timestamp) или Date, в зависимости от архитектуры.
Шаблон 3: “инварианты” должны быть в домене
Не превращайте домен в набор string | null “потому что API так делает”. Если API отдаёт null, это должно учитываться в маппинге:
function mapOptionalStringToNonEmpty(value: string | null): string {
if (value == null) throw new Error("Field is required in domain");
const trimmed = value.trim();
if (!trimmed) throw new Error("Field must be non-empty");
return trimmed;
}
Типизация входных данных и формы: когда DTO нужен на клиенте
Контракт — это не только “ответы”. Запросы тоже стоит типизировать.
Разделите: “формат запроса” и “модель формы”
- Форма UI может хранить строки ввода (например,
totalкак"19.90"), - DTO для API — может требовать другой формат (
total_amountкак строка или число), - Домен — хранит нормализованную структуру.
Типизация полезна, когда маппинг запроса тоже валидирует.
Пример: отправка формы оплаты.
type PaymentFormState = {
orderId: string;
cardNumber: string; // ввод всегда строка
amountInput: string; // "19.90"
};
type CreatePaymentDto = {
order_id: string;
card_number: string;
amount_amount: string;
};
function mapFormToDto(form: PaymentFormState): CreatePaymentDto {
// Валидацию можно вынести в zod, но для примера ограничимся парсингом:
const n = Number(form.amountInput);
if (!Number.isFinite(n) || n <= 0) throw new Error("Amount must be > 0");
// Отправляем в DTO строкой “как требует API”
return {
order_id: form.orderId,
card_number: form.cardNumber.replace(/\s+/g, ""),
amount_amount: form.amountInput,
};
}
На практике лучше использовать схему zod для формы/DTO, чтобы ошибки отображались у пользователя корректно (а не падали в консоли).
Где брать схемы: OpenAPI, генераторы и “source of truth”
Типизация не возникает из воздуха. Важно определить, что является источником правды:
- OpenAPI/Swagger документация,
- Schema в проекте (zod/io-ts),
- Proto/GraphQL схема,
- hand-written DTO + runtime schemas.
Если OpenAPI — источник правды, то идеальная цель: генерировать типы и, желательно, парсеры. Но реальность такая: генерация часто даёт “type-only” информацию без runtime-проверок.
Если вы используете zod (или похожие схемы) как источник правды фронта, то контракт становится двусторонним: вы проверяете ответы, а также формируете запросы в ожидаемом формате. Минус — схема может расходиться с backend, если не синхронизировать процессы.
Выбор зависит от команды и зрелости процесса. Однако в любом случае полезно придерживаться принципа:
Граница (DTO и error-model) должна иметь runtime-проверку или хотя бы единую проверяемую схему.
Типичные ошибки при моделировании контрактов
- “Схема на бумаге” без runtime-валидации. Typescript не проверяет JSON. Если вы не валидируете — вы просто перенесли проблему в compile-time, где она не работает.
- Смешивание DTO и домена. Это усложняет маппинг и делает доменные инварианты недостижимыми.
- Нет discriminated union для ошибок. Если ошибки не имеют
code-дискриминатора или типы не унифицированы, фронт будет работать через строки и условные проверки. - Одинаковый DTO для разных версий. Это скрывает изменения и ломает типизацию “исподтишка”.
- Локальная валидация в компонентах. В итоге валидаторы дублируются, и вы теряете единый контрактный слой.
- Отсутствие контрактных тестов. Схемы меняются, и без тестов вы узнаете об этом только при реальном запросе к API.
Контракт как продукт: как организовать процесс
Технически всё описано выше, но остаётся организационный момент. Контракты живут, а значит нужен процесс:
- backend публикует изменения схем (желательно в OpenAPI),
- фронтенд обновляет DTO-схемы,
- контрактные тесты гоняются на CI,
- error-model и success DTO обновляются вместе,
- версия API отражается и в URL, и в типах.
Если вы хотите углубиться в практики типобезопасного построения контрактов, в экосистеме TypeScript есть разные подходы — от генерации схем до “ручного” runtime-подхода. Как один из вариантов структурировать эти знания можно посмотреть курс, но ключ — внедрить методологию в ваш код: слой границы, схемы, маппинг и контрактые тесты.
Вывод: контракт — это граница с нормализованной правдой
Типизация в TypeScript не заменяет валидацию, но резко снижает вероятность рассинхрона, если вы делаете следующее:
- Разносите домен, DTO и error-model по слоям.
- Делаете маппинг DTO → домен (и при необходимости обратно) чистыми функциями.
- Используете runtime-валидацию схем на границе, чтобы JSON не становился “ложной типобезопасностью”.
- Проектируете ошибки как discriminated union по
code, чтобы фронт мог корректно реагировать. - Версионируете контракты так, чтобы изменения не превращались в “try-catch по всей кодовой базе”.
- Добавляете контрактные тесты и держите source of truth понятным.
В сумме это превращает контракт между фронтом и API из “договорённости в тикете” в работающую архитектурную часть системы — и даёт шанс ловить несовпадения схем намного раньше, чем они затронут пользователей.
Комментарии
Пока нет комментариев