Пошук уроків, статей та іншого контенту
Створюємо типи запитів, відповідей і помилок API, зберігаючи надійний контракт між клієнтом і сервером.
API-контракт описує правила обміну даними між клієнтом і сервером:
які параметри можна передавати в запиті;
які поля є обов’язковими;
у якому форматі повертається успішна відповідь;
які помилки може повернути сервер;
які поля залежать одне від одного.
Якщо контракт описаний лише документацією, клієнт і сервер можуть поступово розійтися. Наприклад, сервер перейменує displayName на name, а клієнт продовжить очікувати старе поле.
TypeScript допомагає синхронізувати код клієнта з контрактом, але важливо розуміти його обмеження:
TypeScript перевіряє типи під час компіляції, але не перевіряє дані, отримані через мережу під час виконання.
Тому надійний контракт складається з двох частин:
статичні типи TypeScript;
перевірка невідомих даних під час виконання.
Розглянемо API для роботи з користувачами.
type UserRole = "admin" | "editor" | "viewer";
type User = {
id: string;
name: string;
email: string;
role: UserRole;
createdAt: string;
};UserRole краще описувати об’єднанням літералів, а не звичайним string. Це забороняє передати довільне значення:
const role: UserRole = "admin";
// Помилка компіляції:
// const invalidRole: UserRole = "owner";Окремо описуємо параметри фільтрації та тіло запиту:
type UsersQuery = {
page?: number;
limit?: number;
role?: UserRole;
search?: string;
};
type CreateUserRequest = {
name: string;
email: string;
role: UserRole;
};Запит до різних endpoint-ів може мати різну структуру. Наприклад:
для GET /users параметри передаються в query;
для POST /users дані передаються в body.
Це потрібно відобразити в типах, а не зводити всі запити до Record<string, unknown>.
type UsersResponse = {
items: User[];
meta: {
page: number;
limit: number;
total: number;
};
};
type CreateUserResponse = {
user: User;
};Зручно використовувати envelope — зовнішню оболонку відповіді:
type ApiSuccess<T> = {
ok: true;
data: T;
};
type ApiFailure = {
ok: false;
error: ApiError;
};
type ApiResult<T> = ApiSuccess<T> | ApiFailure;Поле ok є дискримінантом. За його значенням TypeScript автоматично звужує тип:
function printUserResponse(response: ApiResult<CreateUserResponse>): void {
if (response.ok) {
console.log(response.data.user.name);
} else {
console.error(response.error.message);
}
}Помилки також потрібно описувати як дискриміноване об’єднання:
type ApiError =
| {
status: 400;
code: "VALIDATION_ERROR";
message: string;
fields: Record<string, string[]>;
}
| {
status: 401;
code: "UNAUTHORIZED";
message: string;
}
| {
status: 404;
code: "USER_NOT_FOUND";
message: string;
userId: string;
}
| {
status: 429;
code: "RATE_LIMITED";
message: string;
retryAfter: number;
}
| {
status: 500;
code: "INTERNAL_ERROR";
message: string;
requestId: string;
};Тепер обробник може точно реагувати на різні типи помилок:
function handleApiError(error: ApiError): void {
switch (error.code) {
case "VALIDATION_ERROR":
console.log("Помилки полів:", error.fields);
break;
case "UNAUTHORIZED":
console.log("Потрібно повторно автентифікуватися");
break;
case "USER_NOT_FOUND":
console.log(`Користувача ${error.userId} не знайдено`);
break;
case "RATE_LIMITED":
console.log(`Повторіть через ${error.retryAfter} секунд`);
break;
case "INTERNAL_ERROR":
console.log(`Помилка сервера, requestId: ${error.requestId}`);
break;
}
}Перевага такого підходу — TypeScript не дозволить звернутися до поля, якого немає в конкретній помилці.
Замість розрізнених типів можна створити єдиний контракт API:
type ApiContract = {
"GET /users": {
request: {
query: UsersQuery;
};
response: UsersResponse;
};
"POST /users": {
request: {
body: CreateUserRequest;
};
response: CreateUserResponse;
};
};На основі цієї карти виводимо типи автоматично:
type Endpoint = keyof ApiContract;
type RequestOf<E extends Endpoint> = ApiContract[E]["request"];
type ResponseOf<E extends Endpoint> = ApiContract[E]["response"];Тепер можна створити типізовану функцію запиту:
type Decoder<T> = (value: unknown) => T;
declare function request<E extends Endpoint>(
endpoint: E,
request: RequestOf<E>
): Promise<ApiResult<ResponseOf<E>>>;Виклики перевіряються компілятором:
const usersResponse = await request("GET /users", {
query: {
page: 1,
limit: 20,
role: "viewer",
},
});
const createdUserResponse = await request("POST /users", {
body: {
name: "Олена",
email: "olena@example.com",
role: "editor",
},
});Помилки в аргументах будуть виявлені ще до запуску програми:
// Невідоме значення ролі
await request("POST /users", {
body: {
name: "Олена",
email: "olena@example.com",
// role: "owner",
},
});
// Неправильна форма запиту
await request("GET /users", {
// body: { ... }
});Нижче наведено самодостатній приклад. Він імітує транспортний рівень, перевіряє отримані дані та типізує запити на основі ApiContract.
type UserRole = "admin" | "editor" | "viewer";
type User = {
id: string;
name: string;
email: string;
role: UserRole;
createdAt: string;
};
type UsersQuery = {
page?: number;
limit?: number;
role?: UserRole;
search?: string;
};
type CreateUserRequest = {
name: string;
email: string;
role: UserRole;
};
type UsersResponse = {
items: User[];
meta: {
page: number;
limit: number;
total: number;
};
};
type CreateUserResponse = {
user: User;
};
type ApiError =
| {
status: 400;
code: "VALIDATION_ERROR";
message: string;
fields: Record<string, string[]>;
}
| {
status: 401;
code: "UNAUTHORIZED";
message: string;
}
| {
status: 404;
code: "USER_NOT_FOUND";
message: string;
userId: string;
}
| {
status: 429;
code: "RATE_LIMITED";
message: string;
retryAfter: number;
}
| {
status: 500;
code: "INTERNAL_ERROR";
message: string;
requestId: string;
};
type ApiResult<T> =
| {
ok: true;
data: T;
}
| {
ok: false;
error: ApiError;
};
type ApiContract = {
"GET /users": {
request: {
query: UsersQuery;
};
response: UsersResponse;
};
"POST /users": {
request: {
body: CreateUserRequest;
};
response: CreateUserResponse;
};
};
type Endpoint = keyof ApiContract;
type RequestOf<E extends Endpoint> = ApiContract[E]["request"];
type ResponseOf<E extends Endpoint> = ApiContract[E]["response"];
type Decoder<T> = (value: unknown) => T;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isUserRole(value: unknown): value is UserRole {
return value === "admin" || value === "editor" || value === "viewer";
}
function decodeUser(value: unknown): User {
if (
!isRecord(value) ||
typeof value.id !== "string" ||
typeof value.name !== "string" ||
typeof value.email !== "string" ||
!isUserRole(value.role) ||
typeof value.createdAt !== "string"
) {
throw new Error("Некоректна відповідь користувача");
}
return {
id: value.id,
name: value.name,
email: value.email,
role: value.role,
createdAt: value.createdAt,
};
}
function decodeUsersResponse(value: unknown): UsersResponse {
if (!isRecord(value) || !Array.isArray(value.items) || !isRecord(value.meta)) {
throw new Error("Некоректна відповідь списку користувачів");
}
if (
typeof value.meta.page !== "number" ||
typeof value.meta.limit !== "number" ||
typeof value.meta.total !== "number"
) {
throw new Error("Некоректні метадані пагінації");
}
return {
items: value.items.map(decodeUser),
meta: {
page: value.meta.page,
limit: value.meta.limit,
total: value.meta.total,
},
};
}
function decodeCreateUserResponse(value: unknown): CreateUserResponse {
if (!isRecord(value)) {
throw new Error("Некоректна відповідь створення користувача");
}
return {
user: decodeUser(value.user),
};
}
function isApiError(value: unknown): value is ApiError {
if (!isRecord(value) || typeof value.code !== "string") {
return false;
}
switch (value.code) {
case "VALIDATION_ERROR":
return (
value.status === 400 &&
typeof value.message === "string" &&
isRecord(value.fields)
);
case "UNAUTHORIZED":
return value.status === 401 && typeof value.message === "string";
case "USER_NOT_FOUND":
return (
value.status === 404 &&
typeof value.message === "string" &&
typeof value.userId === "string"
);
case "RATE_LIMITED":
return (
value.status === 429 &&
typeof value.message === "string" &&
typeof value.retryAfter === "number"
);
case "INTERNAL_ERROR":
return (
value.status === 500 &&
typeof value.message === "string" &&
typeof value.requestId === "string"
);
default:
return false;
}
}
function decodeEnvelope<T>(
value: unknown,
decodeData: Decoder<T>
): ApiResult<T> {
if (!isRecord(value) || typeof value.ok !== "boolean") {
throw new Error("Некоректний формат API-відповіді");
}
if (value.ok === false) {
if (!isApiError(value.error)) {
throw new Error("Невідома помилка API");
}
return {
ok: false,
error: value.error,
};
}
return {
ok: true,
data: decodeData(value.data),
};
}
const decoders: {
[E in Endpoint]: Decoder<ResponseOf<E>>;
} = {
"GET /users": decodeUsersResponse,
"POST /users": decodeCreateUserResponse,
};
async function mockTransport(
endpoint: Endpoint,
_request: unknown
): Promise<unknown> {
if (endpoint === "GET /users") {
return {
ok: true,
data: {
items: [
{
id: "u-1",
name: "Олена",
email: "olena@example.com",
role: "editor",
createdAt: "2026-01-10T12:00:00.000Z",
},
],
meta: {
page: 1,
limit: 20,
total: 1,
},
},
};
}
return {
ok: true,
data: {
user: {
id: "u-2",
name: "Андрій",
email: "andrii@example.com",
role: "viewer",
createdAt: "2026-01-11T12:00:00.000Z",
},
},
};
}
async function request<E extends Endpoint>(
endpoint: E,
input: RequestOf<E>
): Promise<ApiResult<ResponseOf<E>>> {
const rawResponse = await mockTransport(endpoint, input);
return decodeEnvelope(rawResponse, decoders[endpoint]);
}
function assertNever(value: never): never {
throw new Error(`Необроблене значення: ${String(value)}`);
}
function handleApiError(error: ApiError): void {
switch (error.code) {
case "VALIDATION_ERROR":
console.log("Помилки валідації:", error.fields);
break;
case "UNAUTHORIZED":
console.log("Користувач не авторизований");
break;
case "USER_NOT_FOUND":
console.log(`Користувача ${error.userId} не знайдено`);
break;
case "RATE_LIMITED":
console.log(`Повторіть через ${error.retryAfter} секунд`);
break;
case "INTERNAL_ERROR":
console.log(`Внутрішня помилка. Request ID: ${error.requestId}`);
break;
default:
assertNever(error);
}
}
async function main(): Promise<void> {
const usersResult = await request("GET /users", {
query: {
page: 1,
limit: 20,
role: "editor",
},
});
if (usersResult.ok) {
for (const user of usersResult.data.items) {
console.log(user.name, user.role);
}
} else {
handleApiError(usersResult.error);
}
const createResult = await request("POST /users", {
body: {
name: "Андрій",
email: "andrii@example.com",
role: "viewer",
},
});
if (createResult.ok) {
console.log("Створено користувача:", createResult.data.user.id);
} else {
handleApiError(createResult.error);
}
}
void main();У реальному застосунку mockTransport буде замінено на fetch, HTTP-клієнт або інший транспорт. Важливо, що дані після отримання з мережі мають тип unknown, доки не пройдуть перевірку.
unknown важливіший за anyНебезпечно типізувати відповідь сервера без перевірки:
async function unsafeRequest(): Promise<User> {
const response = await fetch("/api/users/1");
// Небезпечне припущення: сервер може повернути будь-яку структуру
return (await response.json()) as User;
}as User не перетворює і не перевіряє значення. Якщо сервер поверне:
{
"id": 1,
"username": "olena"
}TypeScript не повідомить про проблему, тому що перевірка відбувається лише під час компіляції.
Безпечніший підхід:
async function safeRequest(): Promise<User> {
const response = await fetch("/api/users/1");
const rawData: unknown = await response.json();
return decodeUser(rawData);
}Тепер неправильна відповідь призведе до помилки на межі системи, а не до прихованої помилки в іншій частині програми.
Іноді assertion необхідний у низькорівневому коді, наприклад під час реалізації універсального клієнта. У такому разі його потрібно ізолювати:
в одному модулі;
біля коду декодування;
після перевірки структури даних;
не поширювати на бізнес-код.
Бізнес-код має працювати вже з перевіреними типами.
Типи запиту й відповіді не завжди є дзеркальними.
Наприклад, клієнт передає:
type CreateUserRequest = {
name: string;
email: string;
role: UserRole;
};А сервер повертає додаткові поля:
type CreateUserResponse = {
user: User;
};Не варто повторно використовувати один тип для обох напрямків:
// Невдалий підхід
type UserPayload = {
id?: string;
name: string;
email: string;
role: UserRole;
createdAt?: string;
};Такий тип допускає багато некоректних станів:
id може бути відсутнім у відповіді;
createdAt може бути відсутнім, хоча сервер завжди його повертає;
клієнт може випадково передати id у запиті створення.
Окремі типи точніше описують контракт і не дозволяють змішувати напрямки даних.
satisfies для перевірки карти контрактуЯкщо endpoint-и зберігаються в конфігурації, оператор satisfies перевіряє її відповідність типу, не втрачаючи конкретних літеральних значень:
const endpointMethods = {
"GET /users": "GET",
"POST /users": "POST",
} as const satisfies Record<Endpoint, "GET" | "POST">;Якщо додати неправильний метод або пропустити endpoint, TypeScript повідомить про це:
const invalidMethods = {
"GET /users": "POST",
"POST /users": "POST",
} as const satisfies Record<Endpoint, "GET" | "POST">;Такий підхід корисний для конфігурацій, де важливо одночасно:
перевірити форму об’єкта;
зберегти точні типи його значень.
Функція assertNever допомагає виявити незакриті варіанти помилки:
function errorMessage(error: ApiError): string {
switch (error.code) {
case "VALIDATION_ERROR":
return "Перевірте введені поля";
case "UNAUTHORIZED":
return "Увійдіть у систему";
case "USER_NOT_FOUND":
return "Користувача не знайдено";
case "RATE_LIMITED":
return "Забагато запитів";
case "INTERNAL_ERROR":
return "Спробуйте пізніше";
default:
return assertNever(error);
}
}Якщо до ApiError додати новий код, наприклад CONFLICT, компілятор повідомить про помилку в цьому switch. Це захищає від ситуації, коли новий тип помилки проходить без правильної обробки.
Зміни контракту потрібно розглядати як зміни публічного інтерфейсу.
Зазвичай менш ризикованими є:
додавання необов’язкового поля у відповідь;
додавання нового endpoint-а;
додавання нового необов’язкового параметра запиту.
Але навіть додавання нового варіанта до об’єднання помилок може вимагати змін у клієнті, якщо використовується вичерпна обробка через assertNever.
Небезпечними є:
перейменування поля;
зміна типу поля;
видалення поля;
перетворення обов’язкового поля на поле з іншою семантикою;
зміна формату помилки;
зміна значення літерального об’єднання.
Наприклад, зміна:
type User = {
id: string;
};на:
type User = {
id: number;
};є несумісною для всіх клієнтів, які очікують рядковий ідентифікатор.
Під час великих змін варто або версіонувати endpoint, або тимчасово підтримувати стару й нову форму відповіді.
any для відповіді APIconst data: any = await response.json();
console.log(data.user.name);any вимикає перевірку типів і дозволяє звертатися до неіснуючих властивостей.
Краще використовувати unknown, а потім виконати декодування.
asconst user = data as User;Assertion лише змінює думку TypeScript про значення. Він не перевіряє дані сервера.
type GenericRequest = Record<string, unknown>;
type GenericResponse = Record<string, unknown>;Такий API майже не має статичної користі: можна передати будь-яку структуру і отримати будь-яке поле.
404 описує транспортний статус, а USER_NOT_FOUND — конкретну бізнес-помилку. Для обробки в коді зручніше використовувати обидва поля:
{
status: 404,
code: "USER_NOT_FOUND",
message: "Користувача не знайдено",
userId: "u-1"
}Обробка лише статусу:
if (error.status === 400) {
// ...
}може бути недостатньо точною. Один статус може відповідати кільком різним сценаріям. Дискримінант code дозволяє обробляти конкретну причину помилки.
Описуйте API через окремі типи запитів, успішних відповідей і помилок.
Використовуйте карту endpoint-ів, щоб пов’язати запит і відповідь на рівні типів.
Для успішних і невдалих результатів застосовуйте дискриміноване об’єднання з полем ok.
Типізуйте помилки через стабільний код, наприклад VALIDATION_ERROR або USER_NOT_FOUND.
Дані з мережі спочатку мають бути unknown, а потім пройти runtime-перевірку.
Не використовуйте any і безконтрольні as на межі API.
Розділяйте типи вхідних даних і даних, які повертає сервер.
Використовуйте assertNever, щоб компілятор виявляв нові необроблені варіанти контракту.