Обнаружение незаметного отклонения контракта API с использованием типов, определяемых на основе примеров, и Zod
Почему ручно написанные типы TypeScript для сторонних API устаревают, как помогает определение типов и схем Zod на основе реальных ответов, и как различия между снимками показывают отклонения.
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 там, где раньше была обычная строка, или дополнительный ключ внутри вложенного объекта. Вместо расплывчатой фразы «где-то что-то пошло не так» вы видите точные различия в структуре.
Именно это сравнение отличает генератор типов от детектора смещений. Генерация кода позволяет создать типизированный код из ничего. Детекция смещений предотвращает то, чтобы изменение имени поля от user_id до userId оставалось незамеченным в производственной среде на недели. В приведённом выше примере разница между снимками состояния будет показывать «user_id удалён, userId добавлен», вместо того чтобы тихо сделать оба поля необязательными.
Храните данные производственных запросов на своём устройстве
Для обнаружения реального смещения требуются реальные данные; синтетические нагрузки не покажут тех изменений, которые вас интересуют. Поэтому конфиденциальность становится требованием к проектированию. Ответы из продакшена могут содержать информацию клиентов, поэтому их вставка в веб-форму, которая загружает их на сервер сторонней организации, создаёт новый риск обработки данных. Инструменты для этой задачи должны работать локально, например полностью в вкладке браузера или как скрипт в вашем собственном репозитории, чтобы нагрузки никогда не покидали вашу среду.
Где подход работает, а где — нет
Инференция на основе примеров с обнаружением смещения не заменяет OpenAPI или инструменты тестирования контрактов, такие как Pact. Если вы владеете как поставщиком, так и потребителем и можете применять схему на этапе разработки, делайте это — это лучший долгосрочный решение.
Эта техника предназначена для более распространённой и менее привлекательной ситуации: вы используете API, которым не управляете, документация к нему устарела или отсутствует, а создание клиента на основе спецификации OpenAPI невозможно из-за отсутствия такой спецификации или недоверия к ней. Это характерно для большинства интеграций с платежными системами, внутренними сервисами, принадлежащими другим командам, и API сторонних поставщиков. В таких условиях реальный ответ является единственным достоверным источником информации, от которого должны производиться ваши типы.
Сохраняйте узкий фокус. JSON — включить, TypeScript и Zod — исключить, плюс обнаружение отклонений, что уже покрывает необходимые функции. Попытки обрабатывать XML, protobuf и все возможные крайние случаи схем превращают эффективный инструмент в нечеткий. Чтобы узнать больше о решениях, связанных с контрактом API, которые часто наносят ущерб фронтенду, ознакомьтесь с типичными ошибками контракта API, разрушающими надежность фронтенда.
Практический первый тест
Лучшее место для пробной работы — это интеграция, уже пострадавшая от скрытых изменений формата данных. Возьмите старый ответ и недавний ответ с того же эндпоинта, пройдите их через алгоритмы инференса и сравнение снимков, а затем проанализируйте, что было отмечено как проблема. Видимость реальных исторических изменений в результате сравнения убедительнее любых аргументов в пользу данного подхода.
Основные выводы
- Ручные интерфейсы для API сторонних разработчиков представляют собой «снимки прошлого», и TypeScript не может определить момент, когда они устаревают.
- Определяйте типы и схемы Zod на основе нескольких реальных ответов, поскольку несколько примеров позволяют обнаружить факультативные, может быть пустыми и несогласованные поля, которые скрывает один пример.
- Проверяйте ответы во время выполнения на границе API, чтобы изменения структуры вызывали явные ошибки вместо того, чтобы возвращать значение
undefined. - Храните ответы в виде «снимков» и сравнивайте новые примеры с ними; одной лишь процедуры инференса может оказаться недостаточно для обнаружения переименования полей, которое может быть воспринято как наличие двух факультативных полей.
- Храните данные, передаваемые в производственной среде, локально и предпочитайте использование OpenAPI или тестов на основе контрактов, когда вы контролируете обе стороны API.
Связанные материалы
- Обнаружение отклонений в контракте API во время компиляции с использованием поэтапного внедрения tRPC — как tRPC превращает изменение названия поля на серверной части в ошибку компиляции, как использовать его постепенно рядом с REST и в каких случаях он не подходит.
- Шесть техник TypeScript, превращающих типы в эффективные средства предотвращения ошибок — узнайте, как операторы satisfies, объединения с метками, конструкция never checks, тип unknown, производные типы и специальные идентификаторы позволяют TypeScript обнаруживать настоящие ошибки уже на этапе компиляции, а не во время работы приложения.