Yamato DaiwaE(CMA)S(cript) extensions

RawObjectDataProcessor — Обзор проблематики

Работа с неизвестными заранее внешними данными — одна из базовых задач программирования. Такими внешними данными могут быть:

  • Данные, полученные с клиентской части в клиент-серверном взаимодействии
  • Наоборот, данные с серверной части в клиент-серверном взаимодействии
  • Данные из базы данных
  • Данные из файла (JSON, YAML и подобных)

Поскольку такие данные находятся вне досягаемости TypeScript, то изначально имеют тип unknown или, что ещё хуже, any.

Как обычно действуют в данной ситуации? К сожалению, зачастую не так, как надлежит при написании качественного кода. Если при получении данных с клиентской части при клиент-серверном взаимодействии валидация данных считается мерой безопасности, а потому осуществляется, то во многих других случаях, например при получении данных с сервера этим данным просто доверяют, а потому помечают их желаемым типом:

Как показывает практика, в подавляющем большинстве проектов среднего и крупного масштабов случается расхождение между ожидаемыми и реальными данными, особенно если клиентская и серверная часть разрабатываются на разных языках программирования и отдельными командами. Число таких расхождений может быть очень большим — от нескольких десятков до нескольких сотен и даже тысяч. Причиной тому может быть как элементарный человеческий фактор, так и отсутствие своевременного уведомления причастных инженеров об изменении в данных.

А если Вы создаёте какую-либо утилиту с декларативной конфигурацией через файл (обычно JSON, YAML и т. д.) наподобие docker compose, то там неверно указанная конфигурация является обычным сценарием. Поэтому перед тем, как работать в внешними данными, их необходимо валидировать, то есть проверить, соответствуют ли реальные данные установленным ограничениям.

Подходы без использования библиотек

Защитники типовнативная функциональность TypeScript. Защитники типов представляют собой функции, возвращающие булевское значение, при этом возвращаемое значение аннотируется не как boolean, а наподобие x is T, где xпараметр, тип которого предстоит проверить, T — желаемый тип:

Помимо документации TypeScript, защитники типов были хорошо разобраны в статье фронтенд-иженера Marius Schulz. Для нас важно что:

Вот пример ошибочного кода, в котором защитник isUser типа User проверят у параметра те поля, которые вообще не имеют отношения к желаемому типу User:

Несмотря на то, что содержимое переменной potentialUser и близко не имеет отношение к типу User, isUser вернёт true и TypeScript даже не заподозрит, что что-то не так. Более того, TypeScript не выразит не малейшего недовольства, если в теле функции-защитника вообще ничего не будет проверяться:

Почему же всё так плохо? Если кратко, то из-за фундаментальных ограничений TypeScript. Валидация данных (в том числе с помощью защитников типов) осуществляется во время выполнения JavaScript-а, когда исходного TypeScript-кода уже нет. В выходном JavaScript-коде защитник типовуже обычная JavaScript-функция, ничем не отличающаяся по своей природе от других функций, возвращающих булевское значение.

Такие особенности языка TypeScript, как алиас типов (ключевое слово type) или интерфейсы существуют только в рамках исходного TypeScript-кода, но в выходном JavaScript-коде их нет, следовательно какого-либо способа сослаться на них тоже нет. Теоретически, можно было бы реализовать генерацию вспомогательных функций и/или объектов на основе алиасов типов и интерфейсов в исходном TypeScript-коде, чтобы потом использовать их для валидации без ручного написания кода, однако маловероятно, что команда разработки TypeScript в ближайшем будущем реализует что-то подобное.

Помимо выше описанной, у защитников типов есть ещё несколько существенных проблем:

  • Защитники типов по своей концепции только отвечают на вопрос, валиден ли параметр или нет, при этом где именно имеются нарушения, сообщено не будет.
  • Защитник типов вернёт false при первом же ложном условии, хотя могут быть и другие ложные условия.

Вообще-то говоря, эти проблемы являются таковым лишь де-факто, потому что как уже было упомянуто выше, при реализации защитников типов TypeScript требует только две вещи: обязательного возврата булевского значения и особой аннотации возвращаемого типа, а в теле функции можно реализовать всё, что угодно, включая логирование, полную проверку всех свойств и так далее. Проблема лишь в том, что в реальности так почти никто не делает, и на то есть веская причина. Если Вы в реальном (не учебном) проекте среднего или большого масштаба начать реализовывать защитники типов с выше перечисленной функциональностью, то очень быстро возникнет ещё одна неприятная проблема: слишком много рутинного кода, причём местами он почти одинаковый. Особенно это касается качественного логирования: будет много однотипных сообщений, и их придётся либо каждый раз писать заново, либо организовывать вынос сообщений в отельные объекты и/или файлы, пока не встанет вопрос о вынесении всего этого кода в библиотеку. А в реальном проекте объекты будут отнюдь не такие простые, как User из примера выше, а могу иметь по 20-30 свойств, и это не предел, причём часто будут и вложенные объекты, в частности массивы, и зачастую с элементами типа «объект», которые тоже надо валидировать. Обилие рутинного кода повышает вероятность ошибок из-за усталости, потому нативное решение задачи хотя имеется, но не является практичным.

Однако, это не делает защитники типов бесполезными — просто они плохо справляются с валидацией объектов с большим количеством свойств, но для других типов значений (строки, числа и так далее) они не просто подходят, но обычно используются в большом количестве. YDEE также предлагает набор защитников типов, многие из которых используются и внутри библиотеки:

Подход RawObjectDataProcessor

Итак, ввиду того что при транспайлинге TypeScript в JavaScript интерфейсы и алиасы типов ( ключевое слово type) прекращают своё существование, на них во время выполнения JavaScript-кода сослаться никак нельзя, потому исчерпывающе доказать, что конкретный объект имеет конкретный тип невозможно — ведь даже если есть соответствующий защитник типа, находящаяся внутри него логика может вообще не иметь отношения к проверке на нужные свойства. Однако перед тем, как использовать ключевое слово as, следует это использование чем-либо подкрепить, а именно валидацией, при этом в целях поддерживаемости валидатор должен детально логировать все несоответствия установленным, а не только первое из них. При таком раскладе, даже если указанные правил валидации и не будут соответствовать реальному типу (например, из-за допущенной по усталости ошибки), как показывает практика, это очень быстро обнаруживается.

Таким образом, RawObjectDataProcessor берёт на себя грех использования as, требуя взамен указать спецификацию валидных данных почти что в декларативном виде. Рассмотрим ещё раз демо в свете описанной выше теории.

Сначала RawObjectDataProcessor проверит, соответствует ли реальное значение externalData спецификации, переданной через второй параметр. Как и обычный защитник типов, RawObjectDataProcessor программно проверяет свойства, однако если что-то не так, то не прекращает проверку немедленно (за исключением случаев, когда сам externalData не является объектом), а сохраняет сообщение о несоответствии в externalDataProcessingResult.validationErrorsMessages: