open atlas
↑ К треку
Система типов TypeScript вглубь TS · 08 · 01

Типизация API: тип на fetch — это обещание, а не доказательство

Аннотация типа на ответе fetch — это обещание о форме сервера, а не доказательство; `as User` не проверяет ничего. Моделируйте ответы как размеченные объединения и Result-типы и считайте сетевую границу местом, где статические типы кончаются и начинается рантайм-валидация.

TS Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Пейджер срабатывает в 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; // ^? User

as 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. 1 Вызвать fetch и сделать await res.json() — результат имеет тип any
  2. 2 Достичь сетевой границы: гарантии этапа компиляции прекращаются, байтам нет доверия
  3. 3 Выбрать валидацию, а не каст: запустить рантайм-проверку, осматривающую фактические байты
  4. 4 При успехе сузить до типизированной формы; при отказе явно обработать ветку ошибки
Вспомните перед уходом
  1. 01
    Почему `(await res.json()) as User` называют ложью и когда `as` законно уместен?
  2. 02
    Что такое «сетевая граница» и почему она важна для типизации?
  3. 03
    Как размеченные объединения и Result<T,E> улучшают типизацию API и что они НЕ чинят?
Итог

Теперь вы относитесь к аннотации fetch как к непроверенному обещанию, видите, почему as User несостоятелен на границе, и моделируете ответы как размеченные объединения и Result-типы, чтобы отказ был частью контракта. Но контракт честен лишь настолько, насколько позволяют байты — каст внутри parseJson — это незакрытый пробел. Дальше zod его закрывает: вы определяете схему один раз, выводите из неё статический тип через z.infer, а .parse/.safeParse осматривают реальные байты в рантайме и сужают — превращая граничное обещание в проверенное доказательство из единого источника истины. Теперь, когда встретите (await res.json()) as ЧтоТо в PR, вы знаете ровно один вопрос: где рантайм-проверка?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.