В редакторе всё зелёное, метод принимает User, а приложение падает на user.name.trim(). Сервер вернул null или поле name стало числом. Код const user = await response.json() as User обещал компилятору, что данные правильные. Обещание не создало проверку. На сетевой границе важно различать то, что мы ожидаем, и то, что действительно получили.
Почему unknown полезнее any
any позволяет обращаться к произвольным свойствам и переносит риск дальше по приложению. unknown требует доказать форму значения перед использованием. Это небольшое неудобство в одном месте экономит отладку в десятках компонентов. Работайте с внешним значением как с входом парсера: функция должна либо вернуть нормальную модель, либо явно сообщить, что контракт нарушен.
Не пытайтесь компенсировать отсутствие проверки оператором ?. во всех компонентах. Если имя обязано существовать, пустая строка вместо неправильного ответа скрывает проблему. Если имя действительно необязательно, это нужно выразить в модели и объяснить пользователю, что означает отсутствие данных. Одинаковый fallback для этих двух случаев делает приложение внешне живым, но логически неверным.
Парсер маленького контракта
Пусть API отдаёт пользователя с положительным целочисленным id и непустым name. Мы не обязаны сохранять все дополнительные поля. Парсер создаёт новый объект, поэтому компоненты получают только согласованную модель. Это не универсальный валидатор; ограниченный контракт делает правила видимыми.
TypeScript// Runtime-проверка входных данных
export type User = { id: number; name: string }
export function parseUser(value: unknown): User {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Ожидался объект пользователя')
}
if (!('id' in value) || typeof value.id !== 'number' ||
!Number.isSafeInteger(value.id) || value.id <= 0) {
throw new Error('Некорректный id')
}
if (!('name' in value) || typeof value.name !== 'string' ||
value.name.trim().length === 0) {
throw new Error('Некорректное имя')
}
return { id: value.id, name: value.name.trim() }
}
Проверка typeof number недостаточна: NaN и Infinity тоже имеют этот typeof, а дробный id может не соответствовать контракту. Number.isSafeInteger заодно исключает числа, которые нельзя точно представить как целое значение в JavaScript. Это правило подходит не любому API: если сервер использует UUID или строковый bigint, модель нужно изменить, а не насильно приводить идентификатор к Number.
Обработайте HTTP и формат независимо
TSX// Нормальная модель выходит только из парсера
export async function loadUser(id: number): Promise<User> {
const response = await fetch('/api/users/' + encodeURIComponent(id))
if (!response.ok) throw new Error('HTTP ' + response.status)
const raw: unknown = await response.json()
return parseUser(raw)
}
Ответ 404 означает одно, JSON неправильной структуры при статусе 200 - другое. В пользовательском интерфейсе оба случая могут дать короткое сообщение, но в диагностике их нужно различать. Иначе изменение серверного контракта выглядит как случайный сетевой сбой. Для большой системы добавьте безопасные коды ошибок и наблюдаемость без записи персональных данных целиком.
Не смешивайте невозможные состояния
Три независимых поля loading, error и user допускают сочетание «идёт загрузка, есть ошибка и старый пользователь». Иногда предыдущие данные действительно нужны, но это должно быть намеренное решение. Для простого экрана объединение вариантов делает допустимые состояния явными.
TypeScript// Состояние описывает допустимые варианты
type UserState =
| { status: 'loading' }
| { status: 'ready'; user: User }
| { status: 'error'; message: string }
export function userLabel(state: UserState): string {
switch (state.status) {
case 'loading': return 'Загрузка'
case 'ready': return state.user.name
case 'error': return state.message
}
}
Проверки, которые действительно находят ошибку
- Корректный объект возвращается как новая нормализованная модель; лишние поля не протекают дальше.
- null, массив, строка и пустой объект отклоняются.
- id = 0, отрицательное число, дробь, NaN и число вне безопасного диапазона отклоняются.
- name = null, число и строка из пробелов отклоняются.
- Смена контракта сервера приводит к контролируемой ошибке экрана, а не к падению в глубоко вложенном компоненте.
Последний пункт проверяется интеграционно: одного unit-теста парсера недостаточно, если экран проглатывает ошибку и бесконечно показывает spinner. После нарушения контракта пользователь должен получить понятный следующий шаг. Сама ошибка не должна превращаться в успешную пустую карточку.
Где остановиться и взять библиотеку
Ручной парсер полезен для небольшого учебного примера. Когда появляются вложенные массивы, объединения вариантов, несколько версий ответа и локализованные сообщения о каждом поле, ручные условия сложнее поддерживать. Выберите библиотеку схем и проверьте её поведение на том же наборе повреждённых данных. Сгенерированный тип из OpenAPI тоже не заменяет runtime-проверку сам по себе.
Продолжите практику: подключите парсер к запросам React, затем сформулируйте контракт ошибок Node.js API. В frontend-программе типизация связана с формами, данными и архитектурой, а не существует отдельно от приложения.
Первичный источник
Правила сужения типов и проверки свойств описаны в TypeScript Handbook: Narrowing. Примеры здесь используют собственный маленький контракт User и не являются универсальной схемой валидации.