Головна / Статті / Виявлення непомітних змін у контракті API за допомогою типів, визначених на основі зразків даних, та Zod

Виявлення непомітних змін у контракті API за допомогою типів, визначених на основі зразків даних, та Zod

Чому ручно написані типи TypeScript для сторонніх API втрачають актуальність, як допомагає визначення типів та схем Zod на основі реальних відповідей, та як відмінності знімків виявляють відхилення.

1565 слів

API від сторонніх розробників можуть змінитися без попередження, і TypeScript цього не помітить, адже ваші типи описують те, як виглядала відповідь на момент її створення, а не те, якою вона є сьогодні. У цій статті розглядається, як виникає ця проблема, чому краще генерувати типи з кількох реальних відповідей, ніж вводити їх вручну, та чому саме порівняння нових відповідей із збереженим знімком захищає вас. Ви також дізнаєтесь, яке місце цей підхід займає поруч із OpenAPI та тестуванням контрактів, а де — ні.

Як поле зі зміненим іменем може прослизнути непоміченим

Уявіть фронтенд, який інтегрується з постачальником платежів. Одного дня поле в одній із відповідей постачальника змінилося з user_id на userId. Не було жодної запису до чарта змін, жодного оголошення та жодного підвищення версії. Найімовірніше, інженер з боку постачальника виправив неузгоджену назву, їхній набір тестів пройшов, і зміна була впроваджена.

Ніщо не збивається з роботи на стороні споживача, а саме в цьому і проблема. Код все ще читає response.user_id, і TypeScript його приймає, оскільки інтерфейс був вручну описаний кілька місяців тому на основі прикладу з Postman, який більше не відображає реальність. Цей інтерфейс все ще обіцяє наявність поля user_id. Під час виконання програми значення просто дорівнює undefined. Протягом двох тижнів три шляхи виконання коду тихо записують undefined у поле з сумою, поки нарешті через заявку на підтримку не виявляється ця помилка. Жодних попереджень не виникає, жоден процес будування не стає червоним, і додаток продовжує робити щось неправильне без жодних скарг.

Команди, які працюють з зовнішніми API протягом тривалого часу, майже завжди стикаються з якоюсь версією такої проблеми.

Слабке місце — це джерело типів

Тут не вина TypeScript. Проблема полягає у походженні типів. Інтерфейси зазвичай розглядаються так, ніби вони походять від чогось авторитетного — наприклад, схеми, контракту чи єдиного джерела істини. Насправді багато з них походять від однієї прикладної відповіді, яку хтось переписав вручну. Цей інтерфейс потім копіюється до кількох інших файлів та сприймається як аксіома, і ніхто більше не дивиться на нього, поки щось не зламається.

Справжнім контрактом є те, що API повертає у режимі продакшну прямо зараз. Воно знаходиться на сервері, яким ви не керуєте, і може змінитися без вашої згоди. Вручну написані типи — це знімок моменту, який вже минув, і компілятор не має способу про це дізнатися.

Існує ще одна глибша проблема. Типи TypeScript зникають під час компіляції, тому на етапі виконання нічого не перевіряється. Якщо змінюється структура даних, лише перевірка на етапі виконання, наприклад розбір відповіді за допомогою схеми Zod, перетворює непомітне значення undefined на негайну, видиму помилку.

Виведення типів з кількох реальних відповідей

Найбільше допомагає тут не більш розвинений TypeScript чи кмітливіші генеричні типи, а механічний процес: брати відповіді, які насправді повертає API, генерувати з них типи та отримувати попередження, як тільки реальні дані перестають відповідати.

Вхідними даними мають бути справжні JSON-відповіді, а не документація чи схема. На їх основі інструмент може визначити як тип TypeScript, так і еквівалентну схему Zod. Використання кількох зразків має більше значення, ніж може здатися. Одна відповідь показує, як може виглядати навантаження даних. Три-чотири відповіді розкривають, які поля справді є необов’язковими, які іноді мають значення null, а також які елементи масиву мають непослідовну структуру. Один зразок завжди вводить в оману через пропуски інформації.

У наведеному нижче прикладі надано дві відповіді для одного ресурсу — одна з user_id, інша з userId — та показано тип TypeScript та схему Zod, визначені на основі обох:

// paste these two responses in...
[
  {
    "user_id": "pot_00009exampleP0tOxWb",
    "name": "Wedding Fund",
    "balance": 550100,
    "currency": "GBP",
    "created": "2025-11-09T12:30:53.695Z",
    "updated": "2025-02-26T07:12:04.925Z"
  },
  {
    "userId": "pot_00009exampleP0tOxWb",
    "name": "Wedding Fund",
    "balance": 550,
    "currency": "EUR",
    "created": "2025-11-09T12:30:53.695Z",
    "updated": "2025-03-26T07:12:04.925Z"
  }
]

// ...get this out typescript
type Root = {
  user_id?: string
  name: string
  balance: number
  currency: string
  created: string
  updated: string
  userId?: string
}[]

// or ... get this out zod
import { z } from 'zod'

const Root = z.array(z.object({
  user_id: z.string().optional(),
  name: z.string(),
  balance: z.number(),
  currency: z.string(),
  created: z.string(),
  updated: z.string(),
  userId: z.string().optional(),
}))

Уважно подивіться на результат об’єднання. Оскільки кожна назва зустрічається лише в одному зразку, як user_id, так і userId стають необов’язковими. Це технічно точно, але це також приховує зміну назви: код, який читає будь-яке з цих полів, все одно перевіряє тип, а відповідь, яка не містить жодного з них, також пройде перевірку за схемою Zod. Зразки також вказують на проблему, яку типовідгадування ніколи не зможе виявити: balance зменшується з 550100 до 550, водночас змінюється currency, що може означати зміну між дробовими та цільовими одиницями валюти. У обох випадках тип, який було визначено, — number. Типовідгадування показує форму даних, але не може розповісти про їхнє значення.

Багато генераторів коду зупиняються на цьому етапі. Перехід від нетипових до типових даних є корисним, але він не вирішує проблеми зміни структури даних.

Знімки та відмінності виявляють зміни

Найцінніший крок відбувається після генерації. Як тільки типи отримуються з реальної відповіді, цю відповідь можна зберегти у вигляді знімка. Кожного разу, коли ви отримуєте новий зразок з того самого кінцевої точки, ви порівнюєте його з знімком та отримуєте точний звіт про те, що змінилося – чи то поле під новою назвою, значення, яке тепер може бути null, де раніше це був звичайний string, чи додатковий ключ усередині вкладеного об’єкта. Замість розмитої фрази „щось зламалося“ ви бачите точну різницю у структурі.

Саме це порівняння відрізняє генератор типів від детектора змін. Генерація коду дозволяє створити типізований код з нічого. Детектор змін запобігає тому, щоб зміна назви від user_id на userId залишалася непоміченою у продакшені протягом тижнів. У наведеному вище прикладі порівняння знімків повідомило б "user_id видалено, userId додано", замість того щоб тихо перетворити обидва поля на необов’язкові.

Зберігайте дані продакшену на своєму пристрої

Для виявлення справжнього зміщення потрібні реальні дані; синтетичні навантаження не покажуть змін, які вас цікавлять. Це робить приватність обов’язковою вимогою до проектування. Відповіді з продакшну можуть містити інформацію про клієнтів, тому їх вставка у веб-форму, яка завантажує їх на сервер сторонньої організації, створює новий ризик обробки даних. Інструменти для цієї роботи повинні працювати локально, наприклад, повністю у вкладці браузера або як скрипт у вашому власному репозиторії, щоб навантаження ніколи не виходили за межі вашого середовища.

Де підходить цей метод, а де — ні

Інференція на основі зразків із виявленням зміщення не замінює OpenAPI чи систему тестування контрактів, таку як Pact. Якщо ви є власником і постачальника, і споживача та можете забезпечити дотримання схеми на рівні вихідних даних, робіть це — це краще рішення у довгостроковій перспективі.

Ця техніка орієнтована на більш поширену та менш привабливу ситуацію: ви використовуєте API, яким не керуєте, документація до нього застаріла або відсутня, і створення клієнта на основі специфікації OpenAPI неможливе через відсутність такої специфікації або через відсутню довіру до неї. Це стосується більшості інтеграцій з процесорами оплат, внутрішніми сервісами, якими керують інші команди, та API сторонніх постачальників. У таких умовах саме фактична відповідь є єдиним достовірним джерелом інформації, тому саме з неї повинні походити ваші типи.

Зберігайте вузьку сферу застосування. JSON – так, TypeScript та Zod – ні, а також функція виявлення змін дозволяють задовольнити потреби. Спроба обробляти XML, protobuf та кожен можливий випадок схеми перетворює ефективний інструмент на нечіткий. Щоб дізнатися більше про рішення щодо контрактів, які часто завдають шкоди фронтенду, перегляньте типові помилки контрактів API, які погіршують надійність фронтенду.

Практичний перший тест

Найкраще протестувати цей підхід у інтеграції, де вже траплялися непомітні зміни форми даних. Візьміть стару відповідь та нещодавню з того ж ендпоїнта, пройдіть їх через аналіз та порівняння знімків, і перегляньте, що було виявлено. Побачити реальну історичну зміну у результаті порівняння переконливіше, ніж будь-які аргументи на користь цього підходу.

Основні висновки

  • Ручні інтерфейси для API сторонніх розробників є кадрами з минулого, і TypeScript не може визначити, коли вони стають застарілими.
  • Визначайте типи та схеми Zod на основі кількох реальних відповідей, адже кілька зразків розкривають необов’язкові, можливо-порожні та непослідовні поля, які приховує один приклад.
  • Перевіряйте відповіді під час виконання програми на межі API, щоб зміни формату призводили до чітких помилок, а не до значення undefined.
  • Зберігайте відповіді у вигляді кадрів та порівнюйте нові зразки з ними; саме процес об’єднання даних може приховати зміну назви поля як два необов’язкові поля.
  • Зберігайте дані для продакшну локально та використовуйте тести OpenAPI чи контрактні тести, коли ви контролюєте обидві сторони API.

Пов’язана література

  • Валідний JSON, пошкоджений контракт: багатошарові перевірки для регресій у вантажі даних — Дізнайтеся, як виявляти регресії у JSON-вантажі даних, які парсуються коректно: семантичні відмінності, фокусована схема JSON, перевірки бізнес-правил у Node та обмеження генерованих типів.
  • Метод HTTP QUERY для команд фронтенду: безпечне читання з тілом — Дізнайтеся, коли метод HTTP QUERY кращий за GET та POST для складних фільтрів, як використовувати його з fetch та яку підтримку CORS, кешування та інфраструктури потрібно.