Пошук уроків, статей та іншого контенту
Типізуєте структуру відповідей API, стани завантаження, помилок і перетворення отриманих даних.
Виклик API повертає дані під час виконання програми. TypeScript не може автоматично гарантувати, що сервер справді надіслав очікувану структуру.
Наприклад, такий код не перевіряє форму відповіді:
const response = await fetch("/api/users");
const users = await response.json();
users.map((user) => user.name);Проблеми можуть виникнути, якщо:
сервер повернув об’єкт замість масиву;
поле name відсутнє або має інший тип;
API повернуло помилку з іншою структурою;
HTTP-запит ще не завершився;
компонент отримав дані, але їх потрібно перетворити для відображення.
Тому типізувати потрібно не лише успішні дані, а весь життєвий цикл запиту:
завантаження;
успішна відповідь;
помилка.
Спочатку опишемо структуру, яку очікуємо від API:
type ApiUser = {
id: number;
name: string;
email: string;
};Цей тип описує саме зовнішню відповідь сервера. Його часто називають ApiUser, UserResponse або UserDto.
Важливо відокремлювати тип відповіді API від типу, який використовує інтерфейс. Сервер може повертати:
type ApiUser = {
id: number;
name: string;
email: string;
};А компоненту може бути зручніше працювати з таким типом:
type UserViewModel = {
id: number;
displayName: string;
email: string;
};UserViewModel містить уже підготовлені дані для відображення.
unknown для JSONРезультат response.json() потрібно розглядати як неперевірені дані. Для цього зручно використовувати unknown:
const payload: unknown = await response.json();На відміну від any, тип unknown не дозволяє звертатися до властивостей або викликати методи, доки ми не перевіримо значення.
const payload: unknown = await response.json();
// Помилка TypeScript:
// payload.map(...)Спочатку потрібно перевірити, що дані мають очікувану структуру.
function isApiUser(value: unknown): value is ApiUser {
if (typeof value !== "object" || value === null) {
return false;
}
const user = value as Record<string, unknown>;
return (
typeof user.id === "number" &&
typeof user.name === "string" &&
typeof user.email === "string"
);
}Тип-предикат value is ApiUser повідомляє TypeScript: якщо функція повернула true, значення можна використовувати як ApiUser.
Перевірку всього масиву можна винести в окрему функцію:
function parseUsers(payload: unknown): ApiUser[] {
if (!Array.isArray(payload) || !payload.every(isApiUser)) {
throw new Error("API повернуло дані неочікуваного формату");
}
return payload;
}Тепер функція або повертає коректний ApiUser[], або повідомляє про помилку.
Для стану запиту зручно використовувати об’єднання типів із полем-дискримінатором:
type LoadState<T> =
| {
status: "loading";
}
| {
status: "success";
data: T;
}
| {
status: "error";
message: string;
};Параметр T робить тип універсальним. Наприклад:
type UserState = LoadState<UserViewModel>;Тепер TypeScript знає:
у стані "loading" поля data немає;
у стані "error" є message;
у стані "success" є data.
Це безпечніше, ніж зберігати окремо кілька змінних:
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [users, setUsers] = useState<UserViewModel[]>([]);Такий підхід дозволяє випадково отримати суперечливий стан: isLoading === true разом із помилкою або старими даними. Об’єднання з status описує лише допустимі комбінації.
Після перевірки даних можна перетворити їх на формат, потрібний компоненту:
function toUserViewModel(user: ApiUser): UserViewModel {
return {
id: user.id,
displayName: user.name.trim(),
email: user.email.toLowerCase(),
};
}Перетворення краще виконувати після перевірки відповіді. Тоді функція toUserViewModel отримує гарантовано коректний ApiUser.
У прикладі компонент:
завантажує користувачів;
перевіряє JSON-відповідь;
обробляє HTTP-помилки;
перетворює дані;
відображає стани завантаження, помилки й успіху.
import { useEffect, useState } from "react";
type ApiUser = {
id: number;
name: string;
email: string;
};
type UserViewModel = {
id: number;
displayName: string;
email: string;
};
type LoadState<T> =
| {
status: "loading";
}
| {
status: "success";
data: T;
}
| {
status: "error";
message: string;
};
function isApiUser(value: unknown): value is ApiUser {
if (typeof value !== "object" || value === null) {
return false;
}
const user = value as Record<string, unknown>;
return (
typeof user.id === "number" &&
typeof user.name === "string" &&
typeof user.email === "string"
);
}
function parseUsers(payload: unknown): ApiUser[] {
if (!Array.isArray(payload) || !payload.every(isApiUser)) {
throw new Error("API повернуло дані неочікуваного формату");
}
return payload;
}
function toUserViewModel(user: ApiUser): UserViewModel {
return {
id: user.id,
displayName: user.name.trim(),
email: user.email.toLowerCase(),
};
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "Сталася невідома помилка";
}
export function UsersList() {
const [state, setState] = useState<LoadState<UserViewModel[]>>({
status: "loading",
});
useEffect(() => {
let isCancelled = false;
async function loadUsers() {
setState({ status: "loading" });
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
);
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}
const payload: unknown = await response.json();
const apiUsers = parseUsers(payload);
const users = apiUsers.map(toUserViewModel);
if (!isCancelled) {
setState({
status: "success",
data: users,
});
}
} catch (error: unknown) {
if (!isCancelled) {
setState({
status: "error",
message: getErrorMessage(error),
});
}
}
}
loadUsers();
return () => {
// Ігноруємо результат запиту після демонтування компонента.
isCancelled = true;
};
}, []);
if (state.status === "loading") {
return <p>Завантаження користувачів...</p>;
}
if (state.status === "error") {
return <p role="alert">Не вдалося завантажити дані: {state.message}</p>;
}
return (
<ul>
{state.data.map((user) => (
<li key={user.id}>
<strong>{user.displayName}</strong>
<span> — {user.email}</span>
</li>
))}
</ul>
);
}Умови state.status звужують тип автоматично. Після перевірки:
if (state.status === "error") {
state.message;
}TypeScript знає, що message існує. Після перевірки:
if (state.status === "success") {
state.data;
}TypeScript знає, що data має тип UserViewModel[].
fetch не вважає HTTP-помилки винятками. Наприклад, відповідь зі статусом 404 або 500 сама по собі не переходить у catch.
Тому потрібно перевіряти response.ok:
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`Помилка HTTP: ${response.status}`);
}Лише після цієї перевірки варто обробляти тіло успішної відповіді.
unknownУ сучасному TypeScript значення в catch може мати тип unknown:
try {
// запит
} catch (error: unknown) {
// error не можна одразу використовувати як Error
}Неправильно:
catch (error: unknown) {
setError(error.message);
}У unknown немає гарантованого поля message. Безпечніше перевірити тип:
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "Сталася невідома помилка";
}Це також враховує ситуації, коли код або бібліотека викидає не екземпляр Error, а рядок чи інше значення.
const users = (await response.json()) as ApiUser[];as ApiUser[] не перевіряє дані під час виконання. Воно лише змінює припущення TypeScript. Якщо сервер надіслав неправильну структуру, помилка виникне пізніше.
Краще отримати unknown і перевірити значення:
const payload: unknown = await response.json();
const users = parseUsers(payload);anyconst payload: any = await response.json();any вимикає більшість перевірок TypeScript. Для зовнішніх даних краще використовувати unknown, а потім виконувати звуження типу.
Статус 200 не гарантує правильну структуру JSON. Сервер міг повернути неправильний формат через помилку на бекенді або несумісну версію API.
Потрібні обидві перевірки:
response.ok;
структура отриманого JSON.
Якщо компонент напряму використовує складну відповідь сервера, логіка перетворення розподіляється по JSX.
Краще виконати перетворення окремо:
const users = apiUsers.map(toUserViewModel);Тоді JSX працює з готовими даними для відображення.
Набір із isLoading, error і data може дозволити суперечливі комбінації. Дискриміноване об’єднання зі status робить стани взаємовиключними та зрозумілішими для TypeScript.
Відповідь API потрібно описувати окремим типом.
Дані з response.json() варто розглядати як unknown.
Для перевірки структури можна використовувати функції-предикати типу.
HTTP-помилку потрібно перевіряти через response.ok.
Стан запиту зручно описувати дискримінованим об’єднанням loading | success | error.
Дані API краще перетворювати в окремий тип для інтерфейсу.
Значення з catch потрібно безпечно звужувати з unknown до Error.