Как стать автором
Обновить

JS. Валидация данных. Пишем свой YUP

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров4.6K

Для чего нужна валидация при разработке и когда ее применять?

В web разработке при работе с пользовательскими данными валидация должна применяться при получении данных сервисом. Условно можно разделить валидацию на:

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

  • Серверную. Любой код, выполняемый на клиенте, а также запросы, поступающие от клиентского приложения, не могут считаться доверенными и должны быть провалидировано. Нельзя рассчитывать на то, что клиентское приложение гарантированно подготовит корректные данные, так как при разработке может возникнуть несоответствие логики работы с данными на сервере и клиенте. При этом мы также можем столкнуться со случаем, когда клиент вручную подготавливает данные, маскируясь под приложение.

В целом, данные следует валидировать как можно чаще, особенно в контексте полного цикла разработки как на сервере, так и на клиенте. Давайте рассмотрим, какие библиотеки существуют для этой цели в настоящее время.

Анализ существующих решений

Из популярных решений которые могут применять как на клиенте, так и на сервере можно выделить yup и zod. Рассмотрим их особенности и обратим внимание на их недостатки.

В целом обе библиотеки страдают от:

  • Излишнее многообразие функциональности. К этому можно отнести как и преобразование типов - обе библиотеки предоставляют функциональность преобразования типов при валидации, так и стремление предусмотреть все возможные случаи валидации. Это увеличивает размер кодовой базы и уменьшает понятность кода для других разработчиков, которые решаться залезть в исходники. Для примера метод getIn в yup и непроходимое поле regexp, методы которые обязаны предусматривать все варианты конфигурации в zod (Это не говоря уже о файлах размером в 6000 строк.).

  • Игнорирование вопросов производительности. Обе библиотеки делают упор скорее на расширении функциональности, чем на производительность того что у них есть. И это проявляется в мелочах, например в этих библиотеках добавление любого нового правила валидации приводит к полному копированию сущности yup, zod.

Архитектура библиотеки

Принципы

Попробуем создать свою библиотеку, избежав указанных выше проблем. Для этого сформулируем принципы которыми мы должно руководстоваться

  • Код должен быть простым

  • Код должен быть производительным на столько, на сколько это позволяет предыдущий пункт

Структура

Попробуем отталкиваться от кода который мы ожидаем видеть в готовой библиотеки. По аналогии с yup и zod выглядеть это должно примерно вот так:

const schema = string().min(2);
const value = 'hello';

schema.validate(value);

Нужно отметить что здесь присутствует две и более валидации

  • string() - проверяет что value является строкой (по умолчанию строка также не должны быть пустой)

  • min(2) - проверяет что длина строки должна быть как минимум 2 символа

Эти условия мы могли бы добавлять и дальше, но мы уже видим главное,

  • структура, которую мы выберем для хранения правил, должна поддерживать неограниченный список правил

  • необходимо предусмотреть цепочку методов, чтобы можно было записать следующее: string().min(2).max(4)

Выглядеть это может так:

type Checker = () => string;
class String {
  conditions: Checker[] = [];

  constructor() {
    // Добавление правила валидации
    this.conditions.push((value) => {
      if (typeof value !== 'string') {
        return 'Is not a string';
      }
      return '';
    });
  }

  min(num: string) {
    // Добавление правила валидации
    this.conditions.push((value) => {
      if (value.length < min) {
        return 'Too short string';
      }
      return '';
    });

    // Возвращение всей сущности для возможности чейнинга
    return this;
  }
}

Теперь для того чтобы узнать и провалидировать передаваемые данные осталось узнать существует ли такой condition который вернет непустую строку при выполнении:

type Checker = () => string;
class String {
  conditions: Checker[] = [];
  // ...
  validate(value: any) {
    for (const condition of this.confiditons) {
      const error = condition(value);
      if (error !== '') {
        return error;
      }
    }

    return '';
  }
}

Здесь можно заметить что мы останавливаемся на первой встреченной ошибке и завершаем цикл проверок. В реальном мире это добавит производительности нашему решению. Такому же подходу мы будет следовать при работе с другими данными, например, объектами - прерывать перебор при выявлении первое ошибки. Такое решение может кому-то показаться странным, но на мой взгляд оно является самым практичным:

  • Если нам интересна каждая ошибка в данных, например при валидации форм. Для каждой сущности(инпута) можно написать свою валидацию

  • Если нам интересно почему сервер не принял наши данные, и мы предполагаем что есть несколько причин. Сначала можно исправить уже указанную ошибку, а потом исправлять новые

Мы уже предусмотрели несколько проверок, но жизнь всегда многообразнее, и стоит позволить разработчику самому добавлять правила помимо стандартных

type Checker = () => string;
class String {
  conditions: Checker[] = [];

  test(checker: (value: any) => string) {
    this.conditions.push(checker);
    return this;
  }
}

Сразу отметим что validate(), test(), conditions() кажутся общими методами/свойствами, без которых не обойдется ни один тип валидации. Поэтому вынесем их в отдельный класс от которого будет наследовать все наши конкретные типы. Финальный код будет выглядеть так:

type Checker = (value: any) => string;
class Schema {
  conditions: Checker[] = [];

  validate(value: any) {
    for (const condition of this.conditions) {
      const error = condition(value);
      if (error !== '') {
        return error;
      }
    }

    return '';
  }

  test(checker: Checker) {
    this.conditions.push(checker);
    return this;
  }
}

class String extends Schema {
  constructor() {
    super();
    this.conditions.push((value) => {
      if (typeof value !== 'string') {
        return 'Is not a string';
      }
      return '';
    });
  }

  min(min: number) {
    this.conditions.push((value) => {
      if (value.length < min) {
        return 'Too short string';
      }
      return '';
    });

    return this;
  }
}

const checkUpperCase = (value: string) => {
  if (value !== value.toUpperCase()) {
    return 'NOT UPPER CASE';
  }

  return '';
};
const string = () => new String();
const schema = string().min(2).test(checkUpperCase);

const valueValid = 'HELLO';
const valueError = 'Hello';

console.log(schema.validate(valueValid)); // ''
console.log(schema.validate(valueError)); // 'NOT UPPER CASE'

Отмечу что реальный пример только немного сложнее, поскольку

  • conditions - должен содержать имена правил, чтобы в определенных случаях их можно было заменить или убрать. Поэтому вместо обычных функций стоит использовать объекты, которые содержат имена проверок и сами функции

  • сообщение об ошибке от checker хотелось бы видеть более информативным, при сложной вложенной структуре в тексте пригодилось бы название свойства в котором произошла ошибка

Вложенные структуры

Мы написали отличный код для примитива, а что делать с более сложными структурами? Например

const user = {
  name: 'Aleksey',
  age: 42,
};

Для этого нам понадобится отдельная сущность object, которая позволит писать вложенные правила

const schema = object({
  name: string(),
  age: number(),
});

Её реализация:

class Object extends Schema {
  constructor(objSchema) {
    super();
    this.conditions.push((obj) => {
      for (const key in objSchema) {
        const innerSchema = objSchema[key];

        // innerSchema сама знает как провалидировать данные, нам остается только ее запустить
        const error = innerSchema.validate(obj);
        if (error !== '') {
          return `${key} props has wrong type`;
        }
      }

      return '';
    });
  }
}

Ts типы

Описывая схему, мы по сути уже указываем типы, которые должны быть в валидируемом объекте. Используя ts мы вполне можем избавить разработчика от необходимости описывать типы несколько раз. Для того чтобы это реализовать попробуем сделать немного магии ts

Простой пример

const schema = string();
const rawValue = 'hello';

const error = schema(rawValue);
if (error !== '') {
  // do something
}

const value = rawValue as Infer<typeof schema>; // string type

Попробуем это реализовать. Как основу идею создадим внутреннее поле types, которое будет хранить тип сущности и откуда Infer сможет получить необходимый тип

class Schema<TValue> {
  types!: TValue;
}

class String extends Schema<string> {}

type Infer<TType extends Schema<any>> = TType['types'];

Работает! Теперь перейдем к более сложному примеру:

const rawUser = {
  name: 'Aleksey',
};

const schema = object({
  name: string(),
});

const error = schema(rawUser);
if (error !== '') {
  // do something
}

const user = rawUser as Infer<typeof schema>; // {name: string, age: number} type

Попробуем реализовать. Сейчас будет немного магии TypeScript, поэтому уберите детей и последователей Flow

type Infer<TType extends Schema<any>> = TType['types'];

class Schema<TValue> {
  types!: TValue;
}

class String extends Schema<string> {}

const string = () => new String();

type ObjectValue = Record<string, Schema<any>>;
type PreparedTypes<TValue extends ObjectValue> = {
  [K in keyof TValue]: Infer<TValue[K]>;
};

class ObjectVidator<
  TValue extends ObjectValue,
  TValueTypes = PreparedTypes<TValue>,
> extends Schema<TValueTypes> {
  value: TValue;

  constructor(value: TValue) {
    super();
    this.value = value;
  }
}

function object<TValue extends ObjectValue>(value: TValue) {
  return new ObjectVidator(value);
}

const schema = object({
  name: string(),
});

type User = Infer<typeof schema>; // {name: string} type

Реальная библиотека

Подходы описанные выше верхнеуровнево описывают концепцию библиотеки которую можно реализовать. Теперь дело за добавлением конкретных типов для number, boolean и так далее. При этом создание реальной библиотеки потребует большее количество ресурсов. Путь описанный выше я проделал при написании своей библиотеки desy. В ней вы можете подсмотреть как выглядит указанный код на самом деле и если захотите использовать в своем проекте

desy - Dead Extraordinary Simple Yup

Мысли о производительности

После написание библиотеки меня удивило на сколько desy оказался более производительным чем другие решения. Я конечно ожидал лучших бенчмарков, но не такого бурного прироста который произошел в реальности. Как причину можно выделить

  • отказ от прокидывания ошибок

  • отказ от валидации при нахождении ошибок

  • отказ от иммутабельных структур и усложненного кода с глубоким ветвлением

Писать конкретные цифры всегда сомнительное дело, поэтому замеры можно изучить самостоятельно

Вопросы, которые могли остаться

  • Почему индикатор ошибки это строка? Строка является самым выразительным средством сообщения о деталях ошибки. Учитывая что мы отказались от пробрасывания ошибок, true/false нам тоже не подойдут

  • Почему не пробрасываем ошибки? Проброс ошибок является операцией которая должна сообщать о непредвиденной работе приложения. Несоответствие данных схеме, при том что это происходит внутри специально созданной для этого программы нельзя назвать непредвиденных ситуациях. Мы буквально просим программу сообщить о том являются ли данные валидными или нет. Для этого должны использоваться обычные способы работы с данными. +производительность

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

Заключение

Как итог хотелось бы сказать:

  • Многие библиотеки которые мы используем в повседневной жизни не являются ни производительными, ни понятными или расширяемыми. Мы просто привыкли к этим инструментам и часто воспринимаем как что-то глобальное и незыблемое. Иногда нужно писать свои велосипеды и возможно какой-то и из них окажется лучше оригинала. Не стоит забывать что многие популярные библиотеки это ответ автора на то, что ему что-то не понравилось в уже существующих

  • Валидируйте данные. Серьезно. Пользователю нельзя доверять. И лучше используйте для валидации desy

Теги:
Хабы:
Всего голосов 7: ↑5 и ↓2+3
Комментарии23

Публикации

Истории

Работа

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область