Недавно я обнаружил, что далеко не все знают об одной простой вещи — и это меня удивило.
Многие думают примерно так: «я описал интерфейс, указал что GET /users возвращает User[], TypeScript подсвечивает все поля, автодополнение работает — значит всё под контролем». Звучит разумно. Но есть одна проблема.
TypeScript работает только во время компиляции. В рантайме его нет.
Когда реальный ответ прилетает с сервера — никакой проверки не происходит. TypeScript просто верит вашей аннотации и молчит. Что реально вернул сервер — string вместо number, переименованное поле, отсутствующий объект — он не знает и знать не будет.
Вы узнаете об этом позже. Обычно от пользователей.
TypeScript — это иллюзия безопасности на границе с внешним миром
Когда вы пишете:
interface User { id: number; name: string; email: string; } const getUser = async (): Promise<User> => { const res = await fetch('/api/user'); return res.json(); // TypeScript просто верит вам на слово };
TypeScript не проверяет, что реально вернул сервер. Вы говорите «здесь будет User» — он верит.
Когда это реально спасает
1. Бэкенд изменил структуру без предупреждения
Было user.full_name, стало user.fullName. Фронт продолжает читать full_name — получает undefined. Без валидации вы узнаете об этом от пользователей или из Sentry.
2. Поле пришло неожиданного типа
Бэкенд вернул "123" вместо 123. TypeScript у вас написано number, но в рантайме — строка. Где-то дальше sum + userId превращается в конкатенацию "1001" — и вы долго ищете баг.
3. Поле отсутствует при определённых условиях
API возвращает address только если пользователь его заполнил. Вы не знали об этом эдж-кейсе, написали user.address.city — и получаете краш у части аудитории.
4. Вы работаете со сторонним API
Документация устарела. Контракт не соблюдается. Поле то есть, то нет. Без валидации вы каждый раз гадаете.
Решение — рантайм-валидация
Для этого существует целый класс библиотек — schema validation (или runtime validators). Их задача: описать ожидаемую структуру данных и проверить реальные данные на соответствие этой структуре во время выполнения программы.
Самые популярные в экосистеме TypeScript:
Zod — наиболее распространённый выбор сегодня, хорошая интеграция с TypeScript, типы выводятся из схемы автоматически
Yup — часто встречается в проектах с Formik
Valibot — молодая альтернатива Zod с акцентом на tree-shaking и меньший бандл
Joi — пришёл из Node.js мира, активно используется на бэкенде
ArkType — новый игрок, ставит на максимальную производительность
Все они решают одну задачу: вы описываете схему — библиотека проверяет данные и говорит, соответствуют ли они ей.
Дальше покажу примеры на основе Zod — он сейчас де-факто стандарт на фронтенде и имеет самую большую экосистему интеграций.
Пример с Zod
Zod позволяет описать схему один раз — и получить сразу и тип, и рантайм-валидацию:
import { z } from 'zod'; const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; // тип выводится автоматически const getUser = async (): Promise<User> => { const res = await fetch('/api/user'); const data = await res.json(); return UserSchema.parse(data); // если структура не та — бросит ошибку };
Вывод
TypeScript — отличный инструмент, но он работает до границы с внешним миром. Всё что приходит снаружи — HTTP-ответы, localStorage, postMessage, данные из URL — не имеет гарантий типов в рантайме.
Рантайм-валидация закрывает эту дыру: вы явно описываете контракт, и любое отклонение от него становится видимым сразу — не через баг-репорт от пользователя.
Это особенно важно в больших командах, где фронт и бэкенд развиваются независимо, и любое изменение API может незаметно сломать клиент.
Комментарии (15)

zoiaylinsk
07.04.2026 10:49Ой да, полностью согласна! Я раньше думала, что раз TypeScript показывает типы, то всё ок, а потом поймала баг из-за того, что сервер прислал совсем не то, что ожидалось. Теперь всегда делаю проверку через Zod реально экономит кучу нервов.

DmitryKazakov8
07.04.2026 10:49В обозримом будущем планирую выпустить новую библиотеку, которая немного с другой стороны подходит к валидации, чем Zod, Valibot и другие schema-first решения.
В ней TS типы запроса и ответа по эндпоинту остаются в текущем виде и являются полноценным источником правды:
import type { TypeUser } from '@models/TypeUser'; type TypeRequest = { id: string; }; type TypeResponse = TypeUser;А библиотека из этого файла генерирует валидаторы, которые можно вызвать в рантайме:
export default { request: { id: 'string' }, response: { id: 'number', name: 'string', email: 'string', }, } as const;Соответственно, остается встроить проверки в функцию вызова апи
import schema from 'validatiors/api/getUser.ts' async function getUser(request: TypeRequest): TypeResponse { // удаляет все лишнее const reqErrors = check({ schema: schema.request, value: request, getExtraneous: console.log }); if (reqErrors) throw new Error(reqErrors); const response = await fetch('/api/user').json(); // удаляет все лишнее const resErrors = check({ schema: schema.response, value: response, getExtraneous: console.log }); if (resErrors) throw new Error(resErrors); // TypeScript верит вам на слово, // но валидаторы не пропустят расхождений по типам и структуре данных return response; };Фронт ясно говорит, что ему нужны только 3 поля в TypeUser, поэтому check очистит легаси, приходящее с бэка, специфику для других платформ, дубликаты с разным неймингом.
getUser вернет 100% соответствующую типам структуру и залогирует в консоль все расхождения в ожиданиях, а при критичных - вернет список понятных ошибок.
Поддерживать TS-типы как источник правды намного проще (они все равно нужны в приложении), чем описывать схемы в Zod-стиле.

Alexandroppolus
07.04.2026 10:49Поддерживать TS-типы как источник правды намного проще (они все равно нужны в приложении), чем описывать схемы в Zod-стиле.
Но схемы умеют делать дополнительные проверки, например по регексу.
А вообще, имхо, источником правды должен быть сваггер/openApi, из которого на фронте генерятся типы и (при необходимости) валидаторы.

DmitryKazakov8
07.04.2026 10:49Zod - инструмент с широким назначением, может и формы валидировать, и проверять данные на email, длину, маски, использоваться на бэке и в пайплайнах нормализации.
Но на фронте задача другая - если использовать TS, то важна именно совместимость по типам - что пришла строка, а не число (и неважно - это email или isoDate), так обеспечивается работоспособность рантайма. А проверки на email в основном нужны бэку при сохранении данных (ну и для форм если надо - можно zod или аналог прикрутить).
При этом намного удобнее не полностью Swagger Open API переносить в проект, а детально заводить типы - если фронту в TypeUser нужно только 3 поля, а не 800 присылаемых бэком с relationships, ios / android / tv спецификой, то лучше так и завести. Это же потом и стабильность улучшит - не будет ломаться при изменении неиспользуемых полей или ошибок в них, фронтендер всегда может сказать "мы используем 3 поля, проверьте не устарела ли выдача из 800 полей", трафика меньше, тестировать проще, в сторы фронта не протекает лишнего и т.п. Думаю любой найдет много причин держать код и рантайм чистыми, а не "тащить все что дают".

CoriolisA
07.04.2026 10:49Продолжу мысль, с Вашего позволения: … а сваггер/openAPI схемы должны генериться из TypeScript типов :) Логично же?

Alexandroppolus
07.04.2026 10:49По разному бывает. На моей прошлой работе, например, схему (в дополнение к ТЗ) составляли аналитики, натурально записывая файлы вручную.

Metotron0
07.04.2026 10:49Оно у вас будет работать с типами вида user: { gender: 'F', childrenCount: number } | { gender: 'M', salary: number }?

DmitryKazakov8
07.04.2026 10:49
Да, там делаю очень замороченный TS AST парсер с поддержкой всего, что может быть в Open API, включая реэкспорты, алиасы, построение инверсированных зависимостей (дерева импортов). Конкретно для этого кейса будет такой валидатор
user: [ 'union', { gender: ['literal', 'F'], childrenCount: 'number' }, { gender: ['literal', 'M'], salary: 'number' } ]И если бэк пришлет
{ gender: 'M', salary: '100' }то будет ошибка response.user is none of 2 types. Для дебага должно быть достаточно.Ну и в любом случае это лучше, чем ловить где-то при открытии модалки undefined has no method toFixed in user.salary.toFixed()

ValeryIvanov
07.04.2026 10:49В угоду обратной совместимости, многие вещи в тайпскрипте возвращают any, вместо нормального типа. Если бы
Response.json()возвращал unknown, то подобной проблемы бы не возникло и программисту пришлось бы разбираться в чём дело(конечно, многие бы просто забили и скастили бы тип к нужному, но всё же).
ReturnVoid
07.04.2026 10:49Есть маленькие библиотеки, которые исправляют эти косяки, например ts-reset, с ними жить становится чуть безопаснее.

nihil-pro
07.04.2026 10:49Zod позволяет описать схему один раз — и получить сразу и тип, и рантайм-валидацию:
import { z } from 'zod'; const UserSchema = z.object({ id: z.number(), name: z.string(), email: z.string().email(), }); type User = z.infer<typeof UserSchema>; // тип выводится автоматически const getUser = async (): Promise<User> => { const res = await fetch('/api/user'); const data = await res.json(); return UserSchema.parse(data); // если структура не та — бросит ошибку };Лично для меня этот подход крайне спорный, и я его не использую из следующих соображений:
Данные пришедшие с сервера по определению валидны, так как они проходят валидацию при сохранении. Нам не нужно проверять что поле
emailэто правда емэйл. Да – правда. Его бы не занесли в базу будь он не валидным.Проверять нужно только соответствие контракту: ждали строку – пришла строка, ждали свойство
userName– пришлоuserName, а неuser_name.Можно валидировать данные перед отправкой, и для этого я использую Constraint Validation –
min,max,patternи пр.Мне не нужны какие-то непонятные схемы, мне нужна конкретная модель сущнсти, и я предпочитаю rich models.
И для этого я написал себе библиотеку, которая решает мои задачи. То что вы называете схемой, у меня – модель:
import { Transformer } from 'kr-transformer'; class UserModel { // Тип – строка name = ''; // Тип – число age = 0; // тип – булево student = false; setName() { // ...какой-то метод } get someComputation() { return this.age * 3; } } const json1 = { name: 'John', age: 42, student: true }; const json2 = { name: 'John', age: "42", student: true }; const json3 = { name: 'John', age: 42, student: null }; const json4 = { username: 'John', age: 42, student: false }; try { // валидный JSON, ошибок нет const model = Transformer.fromJSON(json1, UserModel); } catch(error) { console.log(error) // } try { // age строка – невалидно const model = Transformer.fromJSON(json2, UserModel); } catch(error) { console.log(error) // Unexpected type of <age>, expect Number but receive String } try { // student null – невалидно const model = Transformer.fromJSON(json3, UserModel); } catch(error) { console.log(error) // ...ошибка } try { // username вместо name – невалидно const model = Transformer.fromJSON(json4, UserModel); } catch(error) { console.log(error) // ...ошибка }То есть, полученный с сервера невалидный JSON, просто превратится в модель с понятной ошибкой. Модель сама по себе тип. С моделью дальше гораздо удобнее работать чем с каким-то json-ом.

ReturnVoid
07.04.2026 10:49Важное напоминание, спасибо за статью.
При актуальности проблемы я бы посоветовал разработчикам завести себе BFF, там можно сконцентрировать эти проверки и код для них предотвратив размазывание по проекту.
yarkov
Ну нет же. В этот момент уже никакого TypeScript там нет. Он исчез во время компиляции.
undefinet Автор
Возможно не правильно сформулировал, я просто хотел подсветить здесь, что в данном моменте - типизация != гарантия прихода данных того типа что мы указали.
Delagen
Typescript не врёт, а выполняет свою функцию. То что вы подумали о том, что язык отвечает за данные, то это никогда и нигде так не было. Всё что приходит снаружи подлежит или валидации, или доверию. Язык может лишь подсветить проблемы при правильном использовании, но ни в одном даже очень типизированном языке, как правило, не убирают возможно “небезопасно” скастовать тип. Так это не язык врёт, это программист его неправильно использует