Yamato DaiwaE(CMA)S(cript) extensions

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

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

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

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

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

Если Вы хотите спросить: «Зачем валидировать данные при получении их сервера?», то наш встречный вопрос: «А какова причина, по которой этого делать не надо?». Не кажется ли Вам ответ наподобие «потому что на сервере всегда исключительно верные на 100% данные» далёким от реальности? Как показывает практика, в подавляющем большинстве проектов среднего и крупного масштабов случается расхождение между ожидаемыми и реальными данными, особенно если клиентская и серверная часть разрабатываются на разных языках программирования и отдельными командами. Число таких расхождений может быть очень большим — от нескольких десятков до нескольких сотен и даже тысяч. Причиной тому может быть как элементарный человеческий фактор, так и отсутствие своевременного уведомления причастных инженеров об изменении в данных. Бывает также, что сохранение в базу данных осуществляется разными способами: посредством графического интерфейса приложения, с помощью менеджера баз данных, через выполнение SQL-запросов, через импорт данных и так далее. В зависимости от конкретного способа, данные могут проходить или не  проходить валидацию в полном объёме, что и приводит к сохранению невалидных данных. Такие расхождения нужно обнаружить как можно раньше, и валидация в том числе и при получении данных с сервера — основной инструмент для этого.

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

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

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

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

Вот пример ошибочного кода, в котором защитник isUser типа проверят у параметра те поля, которые вообще не имеют отношения к желаемому типу 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, требуя взамен указать спецификацию валидных данных почти что в декларативном виде. Рассмотрим ещё раз демо в свете описанной выше теории.

  1. Сначала RawObjectDataProcessor проверит, является ли вообще externalData объектом. Если нет, то дальше валидировать и обрабатывать уже нечего.
  2. Далее RawObjectDataProcessor проверит в объекте externalData каждое свойство, упомянутое в спецификации валидных данных validDataSpecification, включая вложенные объекты и массивы (частный случай объектов с точки зрения ECMAScript).

    • Помимо проверки на тип данных, в спецификации валидных данных для примера указаны дополнительные ограничения. Например, свойство bar должно не просто быть строкой, но при этом иметь минимум 5 символов.
    • Для каждого ограничения, указанное в спецификации валидных данных, RawObjectDataProcessor производит конкретную проверку, и если в ходе этой проверки будет выявлено несоответствие реальных данных ожидаемым, то проверка не завершится тут же (за исключением случая, когда сами входные данные не являются объектом), а будет сохранено сообщение о несоответствии в массив, к которому можно будет обратиться через возвращаемое методом process значение.
  3. Если в ходе валидации не будет выявлено ни одного несоответствия реальных данных установленным ограничениям, то тогда RawObjectDataProcessor возьмёт на себя грех пометить с помощью ключевого слова as входные данные тем типом, которые передан через параметр обобщения (SampleType в примере выше).

Между RawObjectDataProcessor и защитниками типов (разумеется, реализованными согласно концепции, но без дополнительной функциональности) имеются следующие сходства:

  1. Проверяют исходные данные ожидаемым
  2. Не могут полностью гарантировать соответствие конкретному типу в силу фундаментальных ограничений TypeScript

Что касается различий, то их гораздо больше:

  1. RawObjectDataProcessor рассчитан на работу только с объектами (в частности, с индексными массивами), хотя их свойства/элементы могут иметь быть любого совместимого с JSON типа.
  2. RawObjectDataProcessor возвращает не  булевское значение, а полиморфный объект. На вопрос, являются ли данные невалидными, отвечает свойство isRawDataInvalid. Когда это свойство имеет значение false, можно обратиться к приведённому к желаемому типу объекту через свойство processedData, в противном случае вместо него будет свойство validationErrorsMessages, содержащее сообщения о всех несоответствиях реальных данных ожидаемым.
  3. API в целом декларативный, хотя при необходимости можно определить дополнительные проверки и манипуляции в императивном стиле.
  4. Может проверить свойства/элементы не только на тип, но и на соответствие другим ограничениям.
  5. При необходимости, помимо валидации может внести изменения в исходный объект. Эта функциональность не является нежелательной, потому что в некоторых случаях она крайне полезна (например, если нужно преобразовать хранящиеся в виде строк числа в тип BigInt, с которым JSON пока что несовместим, или же переименовать свойства), её следует использовать с осторожностью, так как она может поломать валидацию либо сделать помеченные как валидные данные невалидными. Важно, что у RawObjectDataProcessor есть 2 стратегии при работе с объектами: манипуляции с исходным объектом (по умолчанию) и построение нового объекта на основе исходного. Эти стратегии имеют значимость прежде всего в случаях, когда помимо валидации нужно внести изменения в исходный объект.

Наконец, RawObjectDataProcessor имеет качественные шаблоны сообщений о несоответствиях реальных данных ожидаемым, и хотя они документированы, сообщения написаны так, чтобы можно было без документации понять, что именно не так. Посмотрите исходный код этих сообщений (кстати, есть и на русском языке) и представьте, сколько бы времени у Вас ушло на самостоятельную подготовку этих сообщений, включая организацию повторного использования в нескольких проектах.