Типизация API в TypeScript: где рушится доверие к типам
fetch возвращает any, а ваш интерфейс Response — обещание, которое сервер не давал. Разбираем четыре способа связать типы с реальными данными и цену каждого.

Вы описали интерфейс User, привели к нему ответ fetch через as User — и TypeScript замолчал. Проблема в том, что он молчит не потому, что всё правильно, а потому что вы сами ему сказали замолчать. Сервер вернул null вместо email, поле переименовали в user_email, а компилятор ничего не заметил: он проверяет ваши обещания друг с другом, а не с тем, что реально пришло по сети.
Здесь разбираем, как связать типы с фактическими данными от API в TypeScript 5.x. Инвалидация кеша ответов и генерация клиента из OpenAPI — смежные темы, их не трогаем; речь только о границе между сетью и типами.
Откуда берётся any и почему as его не лечит
Стандартный fetch возвращает Promise<Response>, а response.json() — Promise<any>. С этого момента вся типизация — фикция. Любое приведение as SomeType не проверяет данные, оно приказывает компилятору поверить вам на слово.
// TypeScript 5.x
const response = await fetch('/api/user/42');
const user = await response.json() as User;
// user.email — компилятор уверен, что это string
// на деле сервер мог прислать { user_email: null }
console.log(user.email.toLowerCase()); // рантайм-ошибка, типы молчалиКлючевая мысль: as — не проверка, а обещание. Оно нужно, когда вы знаете о типе больше компилятора. При работе с внешним API всё наоборот: компилятор и вы знаете одинаково мало, потому что данные приходят извне процесса.
Четыре подхода и чем каждый платит
Подход 1. Ручной интерфейс плюс приведение as
Пишете интерфейс руками, приводите ответ через as. Быстро, ноль зависимостей, ноль рантайм-стоимости.
Цена: типы живут отдельно от реальности. Как только бэкенд меняет схему, ваши типы врут, а компилятор доволен. Годится для внутреннего прототипа или API, который вы контролируете сами и меняете синхронно.
Подход 2. Валидация схемой (Zod, Valibot)
Описываете схему один раз, тип выводится из неё, а данные проверяются в рантайме на границе.
// TypeScript 5.x, zod 3.x
import { z } from 'zod';
const userSchema = z.object({
id: z.number(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
// Тип выводится из схемы, дублировать не нужно
type User = z.infer<typeof userSchema>;
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/user/${id}`);
const raw = await response.json();
// parse бросит ошибку, если данные не совпали со схемой
return userSchema.parse(raw);
}Теперь если сервер прислал user_email вместо email, parse бросит исключение прямо на границе, а не через пять слоёв кода при вызове .toLowerCase(). Тип и валидация — один источник правды.
Цена: рантайм-оверхед на каждый ответ и вес библиотеки в бандле. Для крупного дерева объектов проверка заметна на горячем пути.
Подход 3. Кодогенерация из OpenAPI
Если у API есть спецификация OpenAPI, типы и клиент генерируются из неё инструментами вроде openapi-typescript. Источник правды — контракт бэкенда, а не ваша ручная копия.
Цена: работает только при наличии актуальной спецификации. Если бэкенд отдаёт устаревший или неточный OpenAPI, вы получите точные типы для неправильной схемы. Плюс шаг генерации в CI и его поддержка.
Подход 4. unknown вместо any на входе
Компромисс без библиотек: типизируете ответ как unknown и разбираете вручную через проверки-предикаты.
// TypeScript 5.x
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as Record<string, unknown>).id === 'number'
);
}
const raw: unknown = await response.json();
if (!isUser(raw)) {
throw new Error('Неожиданный формат ответа /api/user');
}
// внутри блока raw сужен до User
console.log(raw.id);Цена: предикаты пишутся руками и их легко рассинхронить с типом. Компилятор не заставит вас проверить все поля — value is User вы утверждаете сами. Для одного-двух эндпоинтов терпимо, для сотни это Zod, только хуже и вручную.
Сравнение
| Подход | Проверка в рантайме | Оверхед | Источник правды | Когда брать |
|---|---|---|---|---|
| as + интерфейс | нет | нулевой | ваш ручной тип | свой API, синхронные изменения |
| Zod / Valibot | да | рантайм + бандл | схема | внешний API без спеки |
| OpenAPI codegen | нет | шаг сборки | контракт бэкенда | есть актуальный OpenAPI |
| unknown + предикаты | частичная | нулевой | ручной предикат | единичные эндпоинты |
Типизация внешнего API — это не про то, чтобы убрать any. Это про то, где проходит граница между «данным можно верить» и «данные нужно проверить». Всё, что за границей, — unknown, пока вы не доказали обратное.
Когда что выбирать
- Есть OpenAPI и он живой — генерируйте типы, не пишите руками. Ручная копия схемы устаревает молча.
- Внешний API без спеки, данные критичны — Zod на границе. Оверхед проверки дешевле разбора инцидента в проде.
- Свой бэкенд, монорепа, общие типы — можно шарить типы напрямую и обойтись
as, но тогда любое изменение схемы должно проходить через общий тип, иначе гарантия исчезает. - Один-два вызова в скрипте —
unknownс предикатом, без зависимостей.
Что ломается на практике
Три ошибки, которые встречаются чаще всего.
- Валидация только на чтении. Тело POST-запроса вы отправляете тоже как
any. Zod-схему стоит применять и к исходящим данным, иначе граница дырявая с одной стороны. - parse вместо safeParse на пользовательском вводе.
parseбросает исключение. Если данные приходят от пользователя, а не от доверенного сервера, используйтеsafeParseи обрабатывайтеresult.success, чтобы не ронять поток на ожидаемой ошибке валидации. - Оптимистичное
.datetime()без учёта формата. Zod проверяет ISO-8601. Если сервер отдаёт Unix-таймстамп числом, схема упадёт, и это правильно — но упадёт она в проде на реальном ответе, а не на ревью. Сверяйте формат полей с фактическим ответом, а не с ожидаемым.
Отдельная ловушка — z.infer и опциональные поля. z.string().optional() даёт тип string | undefined, а .nullable() — string | null. Сервер, отдающий null, где вы ждали undefined, пройдёт мимо optional() и упадёт на строгой проверке. Читайте, что именно возвращает API: пропуск ключа и явный null — разные вещи.
Что делать дальше
Начните с одного эндпоинта на горячем пути — того, где падение дороже всего. Опишите его схемой Zod, замерьте, добавила ли проверка заметную задержку на вашем объёме данных (для мелких объектов она обычно в пределах шума). Если у бэкенда есть OpenAPI — сравните ручную схему со сгенерированной, расхождения покажут, где ваши типы уже врут. Дальше смотрите на границы, где данные входят в приложение: сеть, localStorage, URL-параметры, тело формы — везде на входе должен быть unknown, а не any.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Zod — официальная документация, TypeScript Handbook: Narrowing (type predicates)
Частые вопросы
А если у меня TypeScript 4.x, а не 5.x — Zod и unknown-предикаты сработают?
Zod parse бросил ошибку в проде на реальном ответе — что делать?
Не проще ли просто отключить проверку any в eslint и жить с as?
Валидация на каждом ответе не замедлит приложение?
Как типизировать ответ, если у API нет OpenAPI и структура плавает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.