Типизация API: тип на fetch — это обещание, а не доказательство
Аннотация типа на ответе fetch — это обещание о форме сервера, а не доказательство; `as User` не проверяет ничего. Моделируйте ответы как размеченные объединения и Result-типы и считайте сетевую границу местом, где статические типы кончаются и начинается рантайм-валидация.
Пейджер срабатывает в 02:00. На странице профиля растёт TypeError: Cannot read properties of undefined (reading 'name'). Вы открываете код, и строка — user.profile.name, а user имеет тип User, где profile необязательным не помечен. Компилятор клялся, что это безопасно. Но три часа назад staging-API выкатил изменение: profile теперь null для удалённых аккаунтов. TypeScript об этом не знал. Аннотация говорила User; сервер прислал другое; никто не проверил. Тип был обещанием о проводе, и провод его нарушил.
fetch отдаёт вам any, а as делает только хуже
response.json() возвращает Promise<any>. Этот any честен: на этапе компиляции TypeScript действительно не знает, какой JSON пришлёт сервер. В момент, когда вы пишете тип, вы утверждаете знание, которое компилятор проверить не может:
const res = await fetch("/api/user/42");
const user = await res.json(); // ^? any
// «Обещаю, что это User» — но это никто не проверяет
const typed = (await res.json()) as User; // ^? Useras User — это одностороннее утверждение. Оно заглушает any и даёт значение, которое остальная программа считает полностью типизированным, — но при этом не выполняет никакой работы в рантайме. Если сервер пришлёт { id: 42, profil: null } (заметьте опечатку, или переименованное поле, или null там, где вы ждали объект), typed.profile.name скомпилируется, уедет в прод и упадёт в рантайме. Каст был ложью, которую система типов поймать не могла.
▸Почему это работает
Почему TypeScript вообще разрешает такой явно несостоятельный каст? Потому что as — это специально спроектированный люк «я знаю то, чего не знает компилятор». Он уместен, когда знание есть у вас — например, document.getElementById("x") as HTMLInputElement, когда HTML писали вы. На сетевой границе этого знания у вас нет: сервер — это отдельный процесс, возможно отдельная команда, возможно другой деплой. Это ровно та ситуация, для которой as никогда не задумывался.
as Response и граница доверия
Данные не заслуживают доверия, и их форма тоже. Представьте круговой рейс: ваш типизированный вызов уходит, возвращаются байты JSON, и тут развилка. Одна ветка кастит — быстро, бесплатно и несостоятельно. Другая валидирует — рантайм-проверка, которая реально осматривает байты и только потом пропускает типизированное значение.
Граница — самое важное понятие этого раздела: это линия, где гарантии этапа компиляции прекращаются. Всё слева от неё (ваши места вызовов, ваш код рендера) доказано проверщиком типов. Всё справа (фактический ответ) — рантайм-данные, которых проверщик не видел. Каст притворяется, что линии нет. Валидация — единственное, что зарабатывает тип.
Моделируйте ответ как размеченное объединение, а не как happy-path-форму
Ещё до валидации вы продвигаетесь дальше, если типизируете реальность. Реальные API возвращают ошибки, а не только успехи. Поле, которое «всегда есть» на счастливом пути, в ветке ошибки — undefined. Смоделируйте оба:
type ApiOk<T> = { ok: true; data: T };
type ApiErr = { ok: false; error: { code: string; message: string } };
type ApiResult<T> = ApiOk<T> | ApiErr;
declare function getUser(id: number): Promise<ApiResult<User>>;
const r = await getUser(42);
if (r.ok) {
r.data; // ^? User — сужено: data есть только в ok-ветке
} else {
r.error.message; // ^? string — error есть только здесь
// r.data; // Error: Property 'data' does not exist on type 'ApiErr'
}Дискриминант ok: true | false (раздел 02) заставляет каждого вызывающего обработать ветку отказа — компилятор отказывается пускать к r.data, пока не доказано r.ok. Это строго лучше, чем возврат User | undefined, который потом затирают через !. Это не валидирует байты, но делает контракт честным в системе типов.
Result<T, E> проталкивает ошибки в значения
Та же идея, обобщённая: вместо того чтобы бросать исключение через границу, возвращайте Result и заставьте вызывающего разобраться с обеими ветками. Ошибки становятся обычными значениями, которые отслеживает проверщик типов:
type Result<T, E = Error> =
| { kind: "ok"; value: T }
| { kind: "err"; error: E };
function parseJson<T>(text: string): Result<T, SyntaxError> {
try {
return { kind: "ok", value: JSON.parse(text) as T };
} catch (e) {
return { kind: "err", error: e as SyntaxError };
}
}
// Заметьте: `as T` внутри — ВСЁ ЕЩЁ ложь — JSON.parse возвращает any.
// Result честно моделирует развилку бросает/не бросает, но не
// проверяет форму успешного значения. Этот пробел закрывает урок 2.Result делает поток управления типобезопасным. Он не делает типобезопасной полезную нагрузку — as T — это та же граничная ложь в более красивом пальто.
Разделение типов между клиентом и сервером
Когда вы обнаруживаете, что копируете серверный интерфейс в фронтенд-файл руками, спросите себя: что произойдёт, когда сервер добавит поле на следующей неделе? Самый чистый способ сделать аннотацию менее ложью — перестать писать её руками. Если клиент и сервер делят один источник истины, тип не сможет тихо разъехаться с байтами:
- Один монорепозиторий / общий пакет — экспортируйте тип ответа из одного модуля, который импортируют обе стороны. Zod-схемы следующего урока и tRPC из урока 3 — два способа сделать это; оба выводят тип из одного определения.
- Типы, сгенерированные из OpenAPI (стандарт описания REST API в машиночитаемом формате) — когда сервер публикует OpenAPI-спецификацию, шаг кодогенерации превращает её в клиентские типы (см. трек APIs про OpenAPI). Сгенерированный
Userмеханически привязан к задокументированному контракту, поэтому он расходится только когда расходится спека — а diff спеки можно отревьюить. Но в рантайме это всё равно не валидирует; баг сервера, нарушающий собственную спеку, проскочит. Генерация сужает пробел расхождения; она не закрывает пробел доверия.
Вы пишете `const u = (await res.json()) as User`. Сервер меняет `profile` с объекта на `null`. Что произойдёт?
Расставьте путь полученного значения от запроса до надёжно типизированного значения:
- 1 Вызвать fetch и сделать await res.json() — результат имеет тип any
- 2 Достичь сетевой границы: гарантии этапа компиляции прекращаются, байтам нет доверия
- 3 Выбрать валидацию, а не каст: запустить рантайм-проверку, осматривающую фактические байты
- 4 При успехе сузить до типизированной формы; при отказе явно обработать ветку ошибки
- 01Почему `(await res.json()) as User` называют ложью и когда `as` законно уместен?
- 02Что такое «сетевая граница» и почему она важна для типизации?
- 03Как размеченные объединения и Result<T,E> улучшают типизацию API и что они НЕ чинят?
Теперь вы относитесь к аннотации fetch как к непроверенному обещанию, видите, почему as User несостоятелен на границе, и моделируете ответы как размеченные объединения и Result-типы, чтобы отказ был частью контракта. Но контракт честен лишь настолько, насколько позволяют байты — каст внутри parseJson — это незакрытый пробел. Дальше zod его закрывает: вы определяете схему один раз, выводите из неё статический тип через z.infer, а .parse/.safeParse осматривают реальные байты в рантайме и сужают — превращая граничное обещание в проверенное доказательство из единого источника истины. Теперь, когда встретите (await res.json()) as ЧтоТо в PR, вы знаете ровно один вопрос: где рантайм-проверка?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.