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