Статический анализатор TypeScript проверяет типы исключительно на этапе компиляции. Когда приложение получает JSON по сети из внешнего REST API, микросервиса или стороннего шлюза, возвращаемые данные имеют тип any или unknown. Слепое утверждение типа response.json() as User создает смертоносную иллюзию безопасности: если бэкенд изменил структуру ответа, переименовал поле или вернул null, приложение упадет с ошибкой TypeError: Cannot read properties of undefined прямо у пользователя. Промышленный стандарт — концепция Parse, don't validate с использованием Zod, алгебраического типа Result Pattern и Branded Types.
В типичных веб-проектах разработка HTTP-клиентов страдает от трех фундаментальных уязвимостей:
const user = (await res.json()) as User. Компилятор доверяет этому утверждению на 100%, однако в случае несовпадения DTO ошибка возникнет не в сетевом слое, а глубоко внутри UI-компонента при попытке отобразить вложенное поле.throw new Error() заставляет вызывающий код помнить обо всех потенциальных исключениях и оборачивать каждый вызов в блоки try/catch. Если разработчик забыл блок catch, весь поток выполнения прерывается.userId и orderId типизированы как обычные string, компилятор никак не помешает случайно передать orderId в функцию fetchUser(id: string).Архитектура надежного взаимодействия изолирует внутреннее ядро приложения от внешнего недоверенного мира:
[ Внешняя сеть / Недоверенная среда ]
│
▼ HTTP Response (Raw JSON / unknown)
┌────────────────────────┐
│ Zod Schema Parser │ <-- safeParse(json)
└───────────┬────────────┘
│
┌─────────┴─────────┐
▼ ▼
[ Success ] [ Failure ]
Типизированный Сформированная
Data Transfer структура ошибки
Object (DTO) ZodError / NetworkError
│ │
▼ ▼
Преобразование в Возврат Err(AppError)
Domain Model с через Result-объединение
Branded Types │
│ │
▼ ▼
[ Ok(DomainEntity) ] [ Err(AppError) ]
└─────────┬─────────┘
│
▼
[ Бизнес-логика / Компоненты UI ]
(Принудительное сопоставление через isOk() / isErr())Определение размеченного объединения Ok<T> и Err<E> со вспомогательными конструкторами и методами безопасного извлечения данных.
Создание generic-типа Brand<K, T> для создания уникальных идентификаторов сущностей (UserId, OrderId, ProductId).
Декларация структуры ответа бэкенда через z.object с автоматическим выводом статических типов через z.infer.
Generic-класс ApiClient с методами get/post, перехватом сетевых сбоев, валидацией статус-кодов и парсингом JSON.
Написание сервиса пользователей с трансформацией валидированного DTO в доменную сущность с брендированными полями.
Почему конструкция `const data = (await res.json()) as User` считается опасным антипаттерном в enterprise-разработке?
Клиент отправляет асинхронный HTTP-запрос с поддержкой AbortSignal, таймаутов и заголовков Accept/Content-Type.
Любые сетевые сбои (DNS, Connection Refused) перехватываются и упаковываются в NetworkError.
Полученный JSON-пейлоад передается в схему schema.safeParse(payload) для строгой структурной проверки без выброса исключений.
При несовпадении типов Zod возвращает подробный путь к невалидному полю (issues list).
Валидный DTO преобразуется в чистую доменную модель, где строковые ID кастуются к Branded типам через валидаторы.
Гарантирует, что ни один UserId не сможет быть передан вместо OrderId.
Вызывающий код производит разбор вариантов через if (result.isOk()) { ... } else { ... } с гарантированной типизацией ветвей.
Полный отказ от конструкций try/catch на уровне пользовательского интерфейса.
Ниже приведены файлы эталонного сетевого слоя приложения.
/**
* Монадический тип Result для строгого управления ошибками.
* Исключает необходимость использования throw / try / catch в бизнес-логике.
*/
export interface Ok<T> {
readonly success: true;
readonly value: T;
}
export interface Err<E> {
readonly success: false;
readonly error: E;
}
export type Result<T, E> = Ok<T> | Err<E>;
export const Result = {
ok<T>(value: T): Ok<T> {
return { success: true, value };
},
err<E>(error: E): Err<E> {
return { success: false, error };
},
isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.success === true;
},
isErr<T, E>(result: Result<T, E>): result is Err<E> {
return result.success === false;
},
unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
return result.success ? result.value : defaultValue;
}
};/**
* Создает номинативный (брендированный) тип на базе примитива.
*/
declare const brandSymbol: unique symbol;
export type Brand<K extends string, T> = T & {
readonly [brandSymbol]: K;
};
// Конкретные доменные идентификаторы
export type UserId = Brand<'UserId', string>;
export type OrderId = Brand<'OrderId', string>;
export type MoneyAmount = Brand<'MoneyAmount', number>;
/**
* Валидатор и конструктор UserId
*/
export function createUserId(raw: string): Result<UserId, string> {
if (raw.trim().length === 0) {
return Result.err('UserId cannot be an empty string');
}
return Result.ok(raw as UserId);
}
/**
* Валидатор и конструктор OrderId
*/
export function createOrderId(raw: string): Result<OrderId, string> {
if (!raw.startsWith('ord_')) {
return Result.err('OrderId must start with ord_ prefix');
}
return Result.ok(raw as OrderId);
}import { z } from 'zod';
import { Result } from '@/types/result';
export interface HttpError {
readonly type: 'NETWORK' | 'HTTP_STATUS' | 'SCHEMA_VALIDATION' | 'UNKNOWN';
readonly message: string;
readonly status?: number;
readonly issues?: z.ZodIssue[];
}
export interface RequestOptions extends RequestInit {
timeoutMs?: number;
}
export class ApiClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.defaultHeaders = {
'Accept': 'application/json',
'Content-Type': 'application/json',
...defaultHeaders,
};
}
/**
* Выполняет типобезопасный GET-запрос с парсингом ответа через Zod-схему
*/
public async get<TSchema extends z.ZodTypeAny>(
endpoint: string,
schema: TSchema,
options: RequestOptions = {}
): Promise<Result<z.infer<TSchema>, HttpError>> {
return this.request(endpoint, 'GET', schema, undefined, options);
}
/**
* Выполняет типобезопасный POST-запрос с парсингом ответа через Zod-схему
*/
public async post<TSchema extends z.ZodTypeAny, TBody>(
endpoint: string,
schema: TSchema,
body: TBody,
options: RequestOptions = {}
): Promise<Result<z.infer<TSchema>, HttpError>> {
return this.request(endpoint, 'POST', schema, body, options);
}
private async request<TSchema extends z.ZodTypeAny, TBody>(
endpoint: string,
method: string,
schema: TSchema,
body?: TBody,
options: RequestOptions = {}
): Promise<Result<z.infer<TSchema>, HttpError>> {
const { timeoutMs = 10000, ...fetchOptions } = options;
const url = `${this.baseUrl}/${endpoint.replace(/^\//, '')}`;
const controller = new AbortController();
const timeoutTimer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method,
headers: { ...this.defaultHeaders, ...fetchOptions.headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
...fetchOptions,
});
if (!response.ok) {
return Result.err({
type: 'HTTP_STATUS',
status: response.status,
message: `HTTP Request failed with status ${response.status} (${response.statusText})`,
});
}
const rawJson: unknown = await response.json();
const parseResult = schema.safeParse(rawJson);
if (!parseResult.success) {
return Result.err({
type: 'SCHEMA_VALIDATION',
message: 'Response payload failed schema validation',
issues: parseResult.error.issues,
});
}
return Result.ok(parseResult.data);
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') {
return Result.err({
type: 'NETWORK',
message: `Request timed out after ${timeoutMs} ms`,
});
}
const message = err instanceof Error ? err.message : 'Unknown network failure';
return Result.err({
type: 'NETWORK',
message,
});
} finally {
clearTimeout(timeoutTimer);
}
}
}import { z } from 'zod';
import { ApiClient, HttpError } from './client';
import { Result } from '@/types/result';
import { UserId, createUserId } from '@/types/branded';
// 1. Схема валидации DTO, приходящего из сети
export const UserDtoSchema = z.object({
id: z.string().min(1),
first_name: z.string(),
last_name: z.string(),
email_address: z.string().email(),
roles: z.array(z.enum(['admin', 'editor', 'member'])),
created_at: z.string().datetime(),
});
export type UserDto = z.infer<typeof UserDtoSchema>;
// 2. Чистая доменная сущность
export interface UserEntity {
readonly id: UserId;
readonly fullName: string;
readonly email: string;
readonly roles: readonly ('admin' | 'editor' | 'member')[];
readonly createdAt: Date;
}
export class UserService {
constructor(private client: ApiClient) {}
public async getUserById(rawId: string): Promise<Result<UserEntity, HttpError>> {
const idResult = createUserId(rawId);
if (!idResult.success) {
return Result.err({
type: 'SCHEMA_VALIDATION',
message: idResult.error,
});
}
const userId = idResult.value;
const responseResult = await this.client.get(`users/${userId}`, UserDtoSchema);
if (Result.isErr(responseResult)) {
return responseResult;
}
const dto = responseResult.value;
// Безопасная сборка доменной модели
const entity: UserEntity = {
id: userId,
fullName: `${dto.first_name} ${dto.last_name}`.trim(),
email: dto.email_address,
roles: dto.roles,
createdAt: new Date(dto.created_at),
};
return Result.ok(entity);
}
}// src/index.ts
import { ApiClient } from '@/api/client';
import { UserService } from '@/api/user-service';
import { Result } from '@/types/result';
async function main(): Promise<void> {
const client = new ApiClient('https://api.example.com/v1');
const userService = new UserService(client);
const result = await userService.getUserById('usr_981247');
if (Result.isOk(result)) {
const user = result.value;
// Полная автодополняемость и гарантия типов
console.info(`Loaded user: ${user.fullName} (${user.email}), ID: ${user.id}`);
} else {
const error = result.error;
console.error(`Failed to load user [${error.type}]: ${error.message}`);
if (error.issues) {
console.error('Schema issues:', error.issues);
}
}
}Вызов метода schema.parse(data) выбрасывает исключение ZodError в случае несоответствия схемы. Это сводит на нет все преимущества Result-паттерна и снова заставляет писать try/catch. Всегда используйте schema.safeParse(data) — он возвращает типизированный объект { success: true, data } | { success: false, error }, идеально сочетающийся с монадическим Result-типом.
Брендированный тип UserId в JavaScript остается обычной строкой. Если вы напишете const upperId = user.id.toUpperCase();, переменная upperId получит базовый тип string, а не UserId. Если вам необходимо сохранить номинативный тип после трансформации, его нужно заново валидировать или явно упаковывать через фабричную функцию.
По умолчанию нативный fetch в браузере и Node.js не имеет таймаута на ожидание ответа. Если удаленный сервер зависнет или сетевое соединение оборвется без отправки FIN-пакета, промис зависнет навечно. Всегда передавайте AbortSignal с таймаутом (например, 10 000 мс) через AbortController.
Некоторые бэкенд-шлюзы при возникновении внутренних ошибок возвращают HTML-страницу ошибки 502 Bad Gateway с заголовком Content-Type: text/html. Попытка вызвать response.json() приведет к синтаксическому сбою парсера JSON SyntaxError: Unexpected token '<'. Обрабатывайте статус-код response.ok строго ДО парсинга JSON-тела.
У вас есть функция deleteOrder(id: OrderId): Promise<Result<void, HttpError>>. Разработчик передает в нее строковую переменную userId, имеющую тип UserId. Что произойдет при компиляции проекта?