Валидация в рантайме
Где заканчивается система типов и начинаются рантайм-проверки — почему TypeScript не может доверять ответу API, схемные валидаторы вроде Zod и выводимые из них типы, кодогенерация из OpenAPI и GraphQL, контрактные тесты и защита сгенерированных типов от рассинхронизации.
8 вопросов
SeniorДизайнОчень частоВы проводите ревью среднего TypeScript-приложения, где валидация разбросана. Схема Zod разбирает форму входа; рукописный type guard isUser проверяет ответы API в одном модуле, а другой модуль просто приводит их через as User; брендированный тип PaymentAmount держится на проверке, закопанной на три слоя внутрь платёжного сервиса. Ревьюеры не могут сказать, какие значения в приложении реально проверены, а какие лишь заявляют тип. Проведите границу. Скажите, что система типов способна обеспечить сама, один раз и бесплатно; что может установить только рантайм-валидатор и как часто он обязан отрабатывать; и где в приложении этот валидатор должен стоять, чтобы всему внутри границы можно было доверять без повторных проверок.
Вы проводите ревью среднего TypeScript-приложения, где валидация разбросана. Схема Zod разбирает форму входа; рукописный type guard isUser проверяет ответы API в одном модуле, а другой модуль просто приводит их через as User; брендированный тип PaymentAmount держится на проверке, закопанной на три слоя внутрь платёжного сервиса. Ревьюеры не могут сказать, какие значения в приложении реально проверены, а какие лишь заявляют тип. Проведите границу. Скажите, что система типов способна обеспечить сама, один раз и бесплатно; что может установить только рантайм-валидатор и как часто он обязан отрабатывать; и где в приложении этот валидатор должен стоять, чтобы всему внутри границы можно было доверять без повторных проверок.
Типы обеспечивают инварианты только между значениями, которые видит компилятор, и только при компиляции. Данные, пересекающие границу — сеть, хранилище, JSON.parse, ввод пользователя, — обязан разобрать рантайм-валидатор прямо на этом краю, один раз.
Типичные ошибки
- ✗Использовать
asдля подтверждения формы — он ничего не проверяет и не может упасть - ✗Перепроверять одно и то же значение на каждом слое вместо разбора один раз на краю
- ✗Ждать от компилятора инварианта, который держится только на данных, которых он не видит
Уточняющие вопросы
- →Какие инварианты стоят брендированного типа, а каким место в схеме?
- →Как не дать вызвать внутренний модуль с данными, миновавшими край?
JuniorТеорияЧастоПочему TypeScript не может проверить данные, которые реально вернул вызов fetch<User>()?
Почему TypeScript не может проверить данные, которые реально вернул вызов fetch<User>()?
Типы стираются при компиляции, поэтому в рантайме от User не остаётся ничего. Обобщение в fetch<User>() утверждает форму, но никогда не смотрит на ответ. Данные на деле остаются unknown, пока их форму не подтвердит рантайм-валидатор.
Типичные ошибки
- ✗Считать, что обобщение в
fetch<User>()заставляет TypeScript проверить ответ в рантайме - ✗Принимать
as Userза проверку — она ничего не проверяет и не может упасть - ✗Забывать, что
JSON.parseвозвращаетany, молча отключая все проверки ниже
Уточняющие вопросы
- →Как схемный валидатор
Zodзакрывает эту дыру? - →Что проверяет
satisfies, чего не проверяетas, и когда работает каждый из них?
MiddleДизайнЧастоВаша команда поддерживает TypeScript-клиент внутреннего REST-сервиса. Сейчас типы запросов и ответов — рукописные интерфейсы в репозитории фронтенда, которые держат в согласии с бэкендом лишь по договорённости: кто менял эндпоинт, тот и должен открыть следующий pull request к клиенту. Бэкенд уже публикует документ спецификации API OpenAPI, сгенерированный из собственных описаний маршрутов и отдаваемый по версионированному URL при каждом развёртывании. Дважды за квартал переименование поля доезжало до продакшена раньше, чем кто-либо трогал клиент, и несовпадение всплывало как undefined глубоко внутри React-компонента, а не на границе сети. Сравните рукописный подход с генерацией типов клиента — и, возможно, самого клиента — из этого документа OpenAPI. Скажите, что генератор действительно убирает, чего он убрать не может и где всё равно нужна проверка в рантайме.
Ваша команда поддерживает TypeScript-клиент внутреннего REST-сервиса. Сейчас типы запросов и ответов — рукописные интерфейсы в репозитории фронтенда, которые держат в согласии с бэкендом лишь по договорённости: кто менял эндпоинт, тот и должен открыть следующий pull request к клиенту. Бэкенд уже публикует документ спецификации API OpenAPI, сгенерированный из собственных описаний маршрутов и отдаваемый по версионированному URL при каждом развёртывании. Дважды за квартал переименование поля доезжало до продакшена раньше, чем кто-либо трогал клиент, и несовпадение всплывало как undefined глубоко внутри React-компонента, а не на границе сети. Сравните рукописный подход с генерацией типов клиента — и, возможно, самого клиента — из этого документа OpenAPI. Скажите, что генератор действительно убирает, чего он убрать не может и где всё равно нужна проверка в рантайме.
Генерация убирает дрейф ручного сопровождения: типы берутся из документа, который публикует бэкенд, поэтому переименование валит сборку. Но доказать, что развёрнутый сервер соответствует спецификации, она не может — ответы валидируются на границе.
Типичные ошибки
- ✗Ждать, что сгенерированные типы докажут соответствие развёрнутого сервера его спецификации
- ✗Считать кодогенерацию заменой валидации ответов на границе
- ✗Перегенерировать только к релизу, из-за чего закоммиченный клиент молча отстаёт от спецификации
Уточняющие вопросы
- →Что сломается, если сам документ спецификации API
OpenAPIотстаёт от развёрнутых маршрутов? - →Коммитить сгенерированный клиент или генерировать его в CI, и почему?
MiddleТеорияЧастоПочему тип лучше вывести из схемы валидатора Zod, а не писать интерфейс руками?
Почему тип лучше вывести из схемы валидатора Zod, а не писать интерфейс руками?
Рукописный interface и отдельный валидатор — два источника истины, которые молча расходятся: компилятор не видит, что проверка перестала совпадать с типом. z.infer<typeof S> оставляет один источник — ту проверку, что реально выполняется.
Типичные ошибки
- ✗Держать
interfaceи валидатор порознь и ждать, что компилятор поймает расхождение - ✗Думать, что
z.inferпорождает схему из интерфейса, а не тип из схемы - ✗Считать, что схема проверяется при компиляции; выполняется только вызов
parse, в рантайме
Уточняющие вопросы
- →Во что превращается
z.infer<typeof S>в сгенерированном JavaScript? - →Как поступить с полем, которое схема отвергает, а приложение может работать без него?
SeniorДизайнЧастоЛегаси REST-эндпоинт, который вы не можете изменить, возвращает для одного и того же ресурса три разные формы в зависимости от того, какой апстрим его обслужил: то объект, где id — число, то объект, где id — строка, то объект, где блок user отсутствует целиком. Ошибки приходят с HTTP 200 и полем error вместо кода статуса. Сейчас клиент типизирует ответ как any, и сбои всплывают как undefined в посторонних компонентах спустя часы. Починить сервер и сделать его согласованным вы не можете. Спроектируйте стратегию типизации и валидации на этой границе: как моделировать ответ, у которого законно несколько форм, что делать с нагрузкой, не подходящей ни под одну из них, и как удержать остальное приложение от встречи с сырым ответом.
Легаси REST-эндпоинт, который вы не можете изменить, возвращает для одного и того же ресурса три разные формы в зависимости от того, какой апстрим его обслужил: то объект, где id — число, то объект, где id — строка, то объект, где блок user отсутствует целиком. Ошибки приходят с HTTP 200 и полем error вместо кода статуса. Сейчас клиент типизирует ответ как any, и сбои всплывают как undefined в посторонних компонентах спустя часы. Починить сервер и сделать его согласованным вы не можете. Спроектируйте стратегию типизации и валидации на этой границе: как моделировать ответ, у которого законно несколько форм, что делать с нагрузкой, не подходящей ни под одну из них, и как удержать остальное приложение от встречи с сырым ответом.
Смоделируйте варианты эндпоинта размеченным объединением в схеме, нормализуйте их на границе в один внутренний тип и там же приведите id. Нагрузка, не подошедшая ни под один вариант, отвергается. За этот край сырой ответ не проходит.
Типичные ошибки
- ✗Типизировать несогласованный ответ как
any, из-за чего сбой всплывает далеко от границы - ✗Принимать HTTP 200 с полем
errorза успех, потому что так говорит код статуса - ✗Делать все поля необязательными вместо моделирования реальных вариантов объединением
Уточняющие вопросы
- →Что делать, когда после выхода схемы в продакшене появляется четвёртая форма?
- →Приводить
idкstringили кnumber, и чего это стоит вызывающему коду?
MiddleДизайнИногдаДва TypeScript-сервиса — API оформления заказа и воркер заказов — обмениваются полезной нагрузкой Order через очередь. Оба импортируют один и тот же тип Order из общего npm-пакета, и команда считает этот общий импорт доказательством согласия сторон: они же компилируются против одного типа, значит, разойтись не могут. Вчера сервис оформления выкатил версию с переименованным полем, опубликованную как минорное повышение; в lock-файле воркера осталась прежняя версия пакета, и он шесть часов молча терял все затронутые заказы. Всё это время оба сервиса компилировались без единой ошибки. Объясните, что общий тип гарантирует и чего не гарантирует между двумя независимо развёрнутыми сервисами, и спроектируйте стратегию проверок — включая место кросс-сервисных контрактных тестов, — которая поймала бы это до того, как воркер начал терять заказы.
Два TypeScript-сервиса — API оформления заказа и воркер заказов — обмениваются полезной нагрузкой Order через очередь. Оба импортируют один и тот же тип Order из общего npm-пакета, и команда считает этот общий импорт доказательством согласия сторон: они же компилируются против одного типа, значит, разойтись не могут. Вчера сервис оформления выкатил версию с переименованным полем, опубликованную как минорное повышение; в lock-файле воркера осталась прежняя версия пакета, и он шесть часов молча терял все затронутые заказы. Всё это время оба сервиса компилировались без единой ошибки. Объясните, что общий тип гарантирует и чего не гарантирует между двумя независимо развёрнутыми сервисами, и спроектируйте стратегию проверок — включая место кросс-сервисных контрактных тестов, — которая поймала бы это до того, как воркер начал терять заказы.
Общий тип ограничивает только код, скомпилированный против этой версии; каждая сторона его стирает и держит свою копию, поэтому в рантайме версии могут разойтись. Контрактные тесты в CI сверяют нагрузку производителя, а потребитель валидирует её при получении.
Типичные ошибки
- ✗Считать общий тип рантайм-контрактом между двумя развёрнутыми сервисами
- ✗Полагать, что оба сервиса одновременно работают на одной версии общего пакета
- ✗Пропускать валидацию у потребителя, потому что производитель уже типизирован
Уточняющие вопросы
- →Что должен утверждать контрактный тест — схему производителя или записанный образец нагрузки?
- →Как выкатывать переименование поля, чтобы сервисы оставались совместимы во время развёртывания?
MiddleДизайнИногдаПродуктовая команда держит сервер на языке запросов GraphQL, написанный на TypeScript, и React-клиент к нему. Типы аргументов и возвращаемых значений резолверов — рукописные интерфейсы рядом с каждым резолвером, а на клиенте тип результата каждого запроса пишут руками, исходя из того, что разработчик ожидает от выборки полей. Сервер публикует схему в виде SDL-файла, а каждая клиентская операция лежит в .graphql-документе, закоммиченном в репозиторий. Две ошибки повторяются: резолвер возвращает поле, объявленное в схеме non-null, хотя код способен выдать null; и компонент читает поле, которого запрос не выбирал, а компилятор это спокойно позволяет, потому что рукописный тип результата его содержит. Сравните ручную типизацию обеих сторон с генерацией типов резолверов из схемы и типов результата из схемы плюс документов. Скажите, что генерация гарантирует и чего она всё ещё не может гарантировать о данных, которые клиент получает в рантайме.
Продуктовая команда держит сервер на языке запросов GraphQL, написанный на TypeScript, и React-клиент к нему. Типы аргументов и возвращаемых значений резолверов — рукописные интерфейсы рядом с каждым резолвером, а на клиенте тип результата каждого запроса пишут руками, исходя из того, что разработчик ожидает от выборки полей. Сервер публикует схему в виде SDL-файла, а каждая клиентская операция лежит в .graphql-документе, закоммиченном в репозиторий. Две ошибки повторяются: резолвер возвращает поле, объявленное в схеме non-null, хотя код способен выдать null; и компонент читает поле, которого запрос не выбирал, а компилятор это спокойно позволяет, потому что рукописный тип результата его содержит. Сравните ручную типизацию обеих сторон с генерацией типов резолверов из схемы и типов результата из схемы плюс документов. Скажите, что генерация гарантирует и чего она всё ещё не может гарантировать о данных, которые клиент получает в рантайме.
Кодогенерация выводит типы резолверов из схемы, а типы результата — из схемы плюс каждого документа, поэтому нарушение non-null или невыбранное поле валят сборку. Но типы описывают схему, а не ответ, и его по-прежнему проверяют в рантайме.
Типичные ошибки
- ✗Писать руками типы результата, включающие поля, которых запрос не выбирал
- ✗Верить, что сгенерированные типы добавляют рантайм-проверки — они стираются, как и любые типы
- ✗Считать, что клиент сверяет ответы со схемой; там её никто не проверяет
Уточняющие вопросы
- →Что генератор кода делает с полем, помеченным в схеме как nullable?
- →Как поведёт себя конвейер, если документ выбирает поле, которое схема уже убрала?
SeniorДизайнИногдаФронтенд генерирует свой TypeScript-клиент из документа спецификации API OpenAPI, который публикует бэкенд. Сгенерированный файл закоммичен и перегенерируется вручную — примерно тогда, когда кто-нибудь вспомнит. В прошлом месяце бэкенд выкатил изменение ответа, которого не было в его же спецификации: обработчик поправили, не тронув аннотацию, из которой она генерируется, — и фронтенд две недели спокойно компилировался против спецификации, уже не совпадавшей с продакшеном. Спроектируйте процесс, который держит сгенерированные типы честными: как и когда запускается генерация, что валит конвейер при изменении спецификации и что ловит случай, когда развёрнутый сервер расходится с собственной опубликованной спецификацией. Скажите прямо, какой из этих двух видов дрейфа кодогенерация обнаружить может, а какой — не может структурно.
Фронтенд генерирует свой TypeScript-клиент из документа спецификации API OpenAPI, который публикует бэкенд. Сгенерированный файл закоммичен и перегенерируется вручную — примерно тогда, когда кто-нибудь вспомнит. В прошлом месяце бэкенд выкатил изменение ответа, которого не было в его же спецификации: обработчик поправили, не тронув аннотацию, из которой она генерируется, — и фронтенд две недели спокойно компилировался против спецификации, уже не совпадавшей с продакшеном. Спроектируйте процесс, который держит сгенерированные типы честными: как и когда запускается генерация, что валит конвейер при изменении спецификации и что ловит случай, когда развёрнутый сервер расходится с собственной опубликованной спецификацией. Скажите прямо, какой из этих двух видов дрейфа кодогенерация обнаружить может, а какой — не может структурно.
Перегенерируйте в CI из спецификации и валите сборку на незакоммиченном диффе: это убивает дрейф спецификации от клиента. Дрейф сервера от спецификации кодогенерация не видит — она читает только документ. Его ловят контрактные тесты и валидация на границе.
Типичные ошибки
- ✗Верить, что кодогенерация обнаружит сервер, разошедшийся с собственной спецификацией
- ✗Перегенерировать вручную вместо падения CI на незакоммиченном сгенерированном диффе
- ✗Считать спецификацию свидетельством о развёртывании, а не об исходниках, из которых её собрали
Уточняющие вопросы
- →Что должно валить конвейер, если спецификация изменилась, а клиент никто не перегенерировал?
- →Где запускать кросс-сервисные контрактные тесты — против стенда или записанной фикстуры?