Runtime-валідація в TypeScript: закрийте прогалину на межі API
TypeScript захищає код під час компіляції, але дані API залишаються неперевіреними під час виконання. Дізнайтеся, як надійно перевіряти межі.
Frontend може пройти всі перевірки TypeScript і все одно впасти, коли backend повертає відсутнє поле, перейменоване значення enum або payload помилки замість обʼєкта користувача, якого очікував інтерфейс. Тип описує те, що застосунок вважає отриманим. Він не доводить, що саме прийшло мережею.
У невеликому застосунку цю прогалину легко ігнорувати. У складному продукті вона стає постійним джерелом порожніх екранів, неправильних UI-станів і багів, які проявляються лише для одного акаунта, feature flag або старої версії API. Практичне рішення — вважати зовнішні дані unknown на межі, перевіряти їх один раз і залишати решту застосунку строго типізованою.
Типи під час компіляції не перевіряють дані мережі
Уявімо типізований API-клієнт, який обіцяє повернути User. Generic-тип робить використання функції зручним, але не перевіряє тіло відповіді. Сервер може повернути некоректний JSON, неповний обʼєкт або успішну відповідь з іншим контрактом — і компілятор цього не дізнається.
type User = {
id: string;
name: string;
role: "admin" | "member";
};
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json() as Promise<User>;
}Наведене твердження змінює уявлення компілятора, але не значення під час виконання. Якщо role дорівнює null або name відсутнє, застосунок усе одно отримає некоректні дані. Помилка виникне пізніше, часто далеко від запиту, який її спричинив.
Зробіть межу явною
Межа має виконувати три завдання: отримати невідомі дані, перевірити їхню структуру та критичні бізнес-обмеження, а потім повернути або довірене значення, або контрольовану помилку. Для цього можна використати Zod, Valibot чи невеликий власний парсер. Важлива не бібліотека, а місце, де відбувається перевірка.
import { z } from "zod";
const userSchema = z.object({
id: z.string(),
name: z.string().min(1),
role: z.enum(["admin", "member"]),
});
type User = z.infer<typeof userSchema>;
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const payload: unknown = await response.json();
return userSchema.parse(payload);
}Тепер тип User і runtime-схема виводяться з одного визначення. Якщо payload некоректний, parse викидає відому помилку валідації на межі API, а не дозволяє пошкодженим даним пройти через компоненти, state store та аналітичний код.
Перевіряйте не лише назви властивостей
Корисна схема описує припущення, від яких насправді залежить продукт. Обовʼязкові поля та примітивні типи — лише початок. Також варто враховувати:
- Значення enum, які можуть змінюватися під час розвитку backend.
- Nullable-поля, які відрізняються від optional-полів.
- Дати, URL, грошові значення та ідентифікатори з вимогами до формату.
- Вкладені обʼєкти, які можуть бути відсутні через права доступу або міркування продуктивності.
- Розділені варіанти відповіді: успіх, помилка валідації та відмова в авторизації.
Не потрібно автоматично відхиляти кожне незнайоме поле. Клієнти, розраховані на сумісність у майбутньому, часто мають приймати додаткові поля від сервера. Суворість варто застосовувати там, де некоректне значення призведе до неправильного рішення, небезпечної дії або зламаного рендерингу.
Не перетворюйте помилки валідації на порожні екрани
Валідація корисна лише тоді, коли UI має продуманий шлях помилки. Request hook повинен розрізняти транспортні помилки, проблеми авторизації та помилки контракту. Для кожного випадку потрібна інша дія: повторити запит після timeout, перенаправити після завершення сесії або повідомити про несумісний payload і локалізувати проблему.
type ApiResult<T> =
| { status: "ok"; data: T }
| { status: "unauthorized" }
| { status: "invalid"; error: Error };
async function loadUser(id: string): Promise<ApiResult<User>> {
try {
const data = await getUser(id);
return { status: "ok", data };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid", error };
}
return { status: "unauthorized" };
}
}У React компонент тоді може рендерити явні стани, а не здогадуватися про них за набором boolean-прапорців. Помилка контракту може показувати користувачу повідомлення з можливістю повторити дію, одночасно записуючи endpoint, версію схеми та безпечні діагностичні дані для команди.
Де команди найчастіше помиляються
- Перевіряють усередині кожного компонента: це дублює логіку і створює непослідовні припущення.
- Використовують type assertions як валідацію: assertions прибирають попередження компілятора, але не перевіряють runtime-дані.
- Парсять лише успішний сценарій: payload помилок і порожні колекції також потребують схем.
- Логують повні payload: відповіді можуть містити персональні або чутливі дані.
- Додають схеми без відповідального власника: схема, яку не оновлюють разом із контрактом API, стає ще одним джерелом плутанини.
Практичний підхід — зберігати схеми поруч із API-функціями, передавати розпарсені доменні значення решті застосунку та тестувати типові payload на межі. Contract tests перевіряють узгодженість frontend і backend, а fixture-тести захищають від регресій у крайніх випадках.
Пропорційний план упровадження
- Почніть з endpoint, які живлять критичні сценарії або часто змінювалися.
- Замініть assertions для відповідей на unknown і runtime-парсинг.
- Визначте спільну модель помилок і перетворюйте помилки валідації на безпечні діагностичні події.
- Додайте fixtures для коректних, неповних, застарілих відповідей і відповідей без авторизації.
- Оцінюйте результат за кількістю помилок рендерингу та швидкістю діагностики, а не за кількістю схем.
Runtime-валідація не означає, що потрібно вручну дублювати кожен backend-тип або блокувати реліз, доки не буде перероблено весь продукт. Це дисципліна роботи з межею: неперевірені дані залишаються неперевіреними, доки їх не перевірено, а решта кодової бази може працювати з припущеннями, які справді відповідають дійсності.
У великих продуктах ця межа часто стає частиною ширшої роботи над frontend-архітектурою та модернізацією. Якщо у вашому застосунку нестабільні API-контракти або захисна логіка розсипана по компонентах, WebMriya може допомогти у розробці складних вебзастосунків чи модернізації legacy-систем.
Поширені запитання
Чи потрібна runtime-валідація, якщо backend і frontend написані на TypeScript?
Так. TypeScript перевіряє ваш вихідний код під час компіляції, але не може перевірити дані, отримані через HTTP. Runtime-валідація захищає межу, через яку зовнішні дані потрапляють у застосунок.
Чи потрібна схема для кожної API-відповіді?
Не обовʼязково робити це одразу для всіх endpoint. Спочатку варто перевірити відповіді, що живлять критичні сценарії, містять введені користувачем дані або часто змінюються, а потім розширювати покриття.
Чи може runtime-валідація замінити тестування API-контрактів?
Ні. Валідація захищає клієнт, коли відповідь уже некоректна, а contract testing допомагає виявити розбіжності між сервісами до релізу. Це повʼязані, але різні задачі.