API вернул priceCents: "неизвестно", и карточка после деления на 100 показала NaN. Примите ответ как unknown, проверьте поля и передайте товар интерфейсу только после успешного разбора.
У нас учебная кружка за 1299 ₽. Название и цена вымышлены; задача настоящая: код должен различать корректный товар, ошибочный ответ сервера и данные другого формата. Готовый проверяющий код находится ниже. Если нужен обзор самих предикатов типов, он есть в статье «Предикаты типов в TypeScript».
Зафиксируйте формат товара
Начните с контракта. Для нашего примера API возвращает один объект: положительный целый id, непустое название name и priceCents — цену в копейках, целую и неотрицательную. Ноль допустим: учебный каталог может содержать бесплатный товар. Дополнительные поля разрешены, но в модель карточки мы их не переносим.
{"id": 7, "name": "Учебная кружка", "priceCents": 129900}
Такой контракт стоит обсудить с разработчиком API до написания проверки. Например, идентификаторы могут быть строками. Если сервер отдаёт цену в рублях, а вы ожидаете копейки, деление на 100 испортит результат, хотя тип number останется верным. Проверяйте соответствие конкретному договору.
В TypeScript модель записывается так:
export type Product = {
id: number;
name: string;
priceCents: number;
};
Тип нужен редактору и компилятору. У сервера он ничего не спрашивает. Числа 1.5, NaN и Infinity тоже имеют тип number, хотя наш контракт их не принимает. Для названия тип string допускает пустую строку. Эти ограничения придётся проверить при выполнении программы.
Оставьте ответ неизвестным
Вот намеренно плохой пример. Не копируйте его в загрузчик:
const product = await response.json() as Product;
Утверждение as Product сообщает компилятору, что вы уже знаете форму ответа. При запуске проверка не появится: TypeScript удаляет утверждения типов при компиляции. Строковая цена останется строкой.
У границы API мы рекомендуем явно писать unknown. Так использовать поля до проверки сложнее: компилятор остановит попытку обратиться к data.name.
const data: unknown = await response.json();
const product = parseProduct(data);
Этот фрагмент станет частью полного загрузчика дальше. Само слово unknown данные не проверяет. Оно помогает удержать правило: внешний ответ сначала проходит разбор, а уже затем получает доверенный тип.
Сначала предскажите, пройдут ли null и [] условие typeof data === 'object'. Запишите гипотезу. Объясните, достаточно ли этого условия, чтобы дальше читать поля товара, затем сверьте свой вывод.
У null результат тоже 'object'; руководство TypeScript по сужению типов объясняет эту особенность и отдельно напоминает, что массивы тоже относятся к объектам. Нужны дополнительные условия. Функция ниже исключает оба случая перед чтением полей.
Для самопроверки выполните в консоли typeof null и typeof []: в обоих случаях получится 'object'. Поэтому проверки typeof недостаточно для нашего контракта.
Проверьте поля до возврата
Сохраните объявление Product и следующие функции в product.ts. Функция parseProduct либо возвращает новый объект с проверенными полями, либо выбрасывает ошибку. Половину товара она не возвращает.
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function parseProduct(value: unknown): Product {
if (!isRecord(value)) {
throw new Error('Товар должен быть объектом');
}
const {id, name, priceCents} = value;
if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) {
throw new Error('У товара неверный id');
}
if (typeof name !== 'string' || name.trim().length === 0) {
throw new Error('У товара нет названия');
}
if (typeof priceCents !== 'number' ||
!Number.isSafeInteger(priceCents) || priceCents < 0) {
throw new Error('У товара неверная цена');
}
return {id, name: name.trim(), priceCents};
}
Record<string, unknown> здесь означает объект, у которого значения свойств ещё неизвестны. Предикат isRecord подтверждает только форму контейнера. Название товара он не проверяет.
Далее каждая проверка отвечает за своё поле. Number.isSafeInteger отсекает дроби, бесконечность и целые числа за пределами безопасного диапазона. Перед ним стоит typeof: строка '129900' не должна стать ценой случайно. Если API обещает число, строка означает нарушение контракта.
У названия убираем пробелы по краям. Строка из одних пробелов поэтому считается пустой. Возвращаем новый объект с нужными полями: даже если в ответе появились debug или admin, они не попадут в карточку через этот разбор.
Это проверка данных, полученных из JSON. Для произвольного объекта JavaScript с геттерами или прокси потребовались бы дополнительные решения. И ещё: предикат — обычная функция. Ошибка в его условии останется ошибкой, поэтому ниже есть плохие ответы для проверки.
Подключите разбор к загрузчику
Добавьте в тот же product.ts загрузчик. Параметр request позволяет при проверке подставить учебный ответ вместо реального запроса; при обычном вызове используется fetch.
export async function loadProduct(
url: string,
request: typeof fetch = fetch,
): Promise<Product> {
const response = await request(url);
if (!response.ok) {
throw new Error(`Ошибка HTTP: ${response.status}`);
}
const data: unknown = await response.json();
return parseProduct(data);
}
Порядок здесь влияет на сообщение об ошибке. Сначала проверяем HTTP-статус: сервер может прислать страницу HTML с кодом 404. Затем читаем JSON и проверяем содержимое. У fetch HTTP-ошибка сама по себе не отклоняет промис, поэтому условие с response.ok нужно написать явно.
По нашему контракту успешный ответ содержит товар. Пустое тело со статусом 204 этому контракту не соответствует, и response.json() завершится ошибкой. Если ваше API действительно использует 204, опишите отдельный результат и обработайте его до чтения JSON.
Чтобы увидеть результат в браузере, положите рядом файл product.json с учебным объектом выше и HTML с блоком <p id="result" role="status">Загружаем товар…</p>. В main.ts импортируйте загрузчик:
import {loadProduct} from './product.js';
const output = document.querySelector('#result');
if (!(output instanceof HTMLElement)) {
throw new Error('Не найден блок результата');
}
async function showProduct(element: HTMLElement): Promise<void> {
try {
const product = await loadProduct('./product.json');
element.textContent = `${product.name}: ${product.priceCents / 100} ₽`;
} catch (error: unknown) {
element.textContent = error instanceof Error
? error.message
: 'Не удалось загрузить товар';
}
}
void showProduct(output);
В существующем проекте TypeScript включите strict, библиотеки ES2022 и DOM, скомпилируйте оба файла в модули JavaScript. Подключите main.js через <script type="module" src="./main.js"></script>. Открывайте страницу через сервер разработки по HTTP. Импорт с окончанием .js указывает на результат компиляции, который будет загружать браузер.
Ожидаемый текст: «Учебная кружка: 1299 ₽». Мы используем textContent, поэтому даже название с угловыми скобками останется текстом и не станет HTML-разметкой на странице.
Испытайте неверные ответы
Проверять только удачный товар недостаточно. Изменяйте по одному полю в product.json, перезагружайте страницу и сверяйте результат. Исходный валидный объект сохраните отдельно, чтобы случайно не проверять сразу несколько поломок.
| Ответ или изменение | Что должно произойти |
|---|---|
null или [] вместо товара |
«Товар должен быть объектом». |
id: 0 или id: 1.5 |
«У товара неверный id». |
name: " " |
«У товара нет названия». |
priceCents: "129900" или priceCents: -1 |
«У товара неверная цена». |
| Удалённая закрывающая скобка JSON | Ошибка разбора JSON, до проверки полей дело не дойдёт. |
Ответ сервера с кодом 404 |
Сообщение с HTTP-статусом. |
Попробуйте доработку с подсказкой: ограничьте название 100 символами. После trim() проверяйте длину. Название из 100 букв должно пройти, из 101 — попасть в ошибку, а пробелы по краям не должны влиять на результат. Проверьте обе границы. Затем верните пример к исходному контракту.
Проверьте свой контракт
Для самостоятельной задачи добавьте необязательное поле imageUrl. Начните с договора. Решите заранее, допускает ли API null, пустую строку и отсутствие свойства, затем напишите проверку и плохой пример для каждого запрета. Не используйте as, чтобы обойти условие, которое пока не удалось описать.
На курсе «TypeScript: теория типов» можно продолжить работу с типами. Перед этим выполните вариацию примера: она покажет, где заканчивается описание модели и начинается проверка ответа.
Для самопроверки ответьте: почему строковую цену мы отклоняем, хотя её можно преобразовать в число? И какой этап должен сообщить об ошибке, если сервер прислал валидный JSON с пустым названием?