Предыстория


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


  • валидировать типы данных;
  • задавать дефолтные значения вместо невалидных полей или элементов;
  • удалять невалидные части объекта или массива;
  • получать сообщение об ошибке;

В основе которой будет:


  • Легкость в освоении
  • Читабельность получаемого кода.
  • Легкость модификации кода

Для достижения этих целей была разработана библиотека валидации quartet.


Основные кирпичи валидации


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


Валидатор


В основе библиотеки quartet — лежит понятие валидатора. Валидаторами в данной библиотеке являются функциями следующего вида


function validator(
  value: any,
  { key: string|int, parent: any },
  { key: string|int, parent: any },
  ...
): boolean

В данном определении есть несколько вещей, которые стоит описать подробнее:


function(...): boolean — говорит о том, что валидатор — вычисляет результат валидации, и результатом валидации является булевое значение — истинно или ложно, соответственно валидно или не валидно


value: any — говорит о том, что валидатор — вычисляет результат валидации значения, которое может быть любым значением javascript'a. Валидатор либо относит данное валидируемое значение к валидным или либо к невалидным.


{ key: string|int, parent: any }, ... — говорит о том, валидируемое значение может быть в разных контекстах в зависимости от того, на каком уровне вложенности находится значение. Покажем это на примерах


Пример значения без какого-либо контекста


const value = 4; 
// Это значение не находится в контексте другой структуры данных.
// Чтобы валидатор его провалидировал он вызывается на самом значении:
const isValueValid = validator(4)

Пример значения в контексте массива


//   ключи   0  1  2  3      4
const arr = [1, 2, 3, value, 5] 
// В массиве данное значение находится под индексом(kеу): 3
// Родителем в данном контексте является массив: [1, 2, 3, value, 5] 
// Поэтому при валидации value - валидатор вызывается с такими параметрами
const isValueValid = validator(4, { key: 3, parent: [1,2,3,4,5] })

Пример значения в контексте объекта


const obj = {
  a: 1,
  b: 2,
  c: value,
  d: 8
}
// В данном обьекте значение имеет ключ равный 'c'
// Родителем в данном контексте является весь объект: { a: 1, b: 2, c: 4, d: 8 }
// Поэтому при валидации value - валидатор вызывается
// с такими параметрами:
const isValueValid = validator(4, { key: 'c', parent: { a: 1, b: 2, c: 4, d: 8 } })

Так как структуры в объекте могут иметь бо?льшую вложенность, то имеет смысл говорить и о множестве контекстов


const arrOfObj = [{
  a: 1,
  b: 2,
  c: value,
  d: 8
},
// ...
]
// В данном cлуче значение имеет ключ равный 'c'
// Первым родителем является объект: { a: 1, b: 2, c: 4, d: 8 }
// В свою очередь родителем родителя является массив arrOfObj,
// в котором объект находится под индексом 0.
// Поэтому при валидации value - валидатор вызывается с такими параметрами
const isValueValid = validator(
  4,
  { key: 'c', parent: { a: 1, b: 2, c: 4, d: 8 } }
  { key: 0, parent: [{ a: 1, b: 2, c: 4, d: 8 }] }
)

И так далее.


О сходстве с методами массивов

Данное определение валидатора должно вам напомнить определение функций, которые передаются в качестве аргумента в методы массивов, такие как: map, filter, some, every и тд.


  • Первым аргументом этих функций является элемент массива
  • Вторым аргументом — индекс элемента
  • Третьим аргументом — сам массив

Валидатор в данном случае является более обобщённой функцией — он принимает не только индекс элемента в массиве и массив, но и индекс массива — в его родителе и его родителя и так далее.


Что нам стоит дом построить?


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


Как построить небоскрёб валидации объектов?


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


// Подключаем библиотеку валидации quartet
const quartet = require('quartet')
// Создаём композитор валидаторов (v - значит validator)
const v = quartet()
// Опишем схему объекта в контексте валидаторов,
// используемых для конкретных полей
const objectSchema = {
  a: a => typeof a ==='string', // Валидатор типа 'string'
  b: b => typeof b === 'number', // Валидатор типа 'number'
 // ...
}
const compositeObjValidator = v(objectSchema)
const obj = {
  a: 'some text',
  b: 2
}
const isObjValid = compositeObjValidator(obj)
console.log(isObjValid) // => true

Как видим, из разных кирпичей-валидаторов, определённых для конкретных полей, мы можем собрать валидатор объекта — некоторое "маленькое здание", в котором ещё довольно тесно — но уже лучше чем без него. Для этого мы используем композитор валидаторов v. Всякий раз, встречая объектный литерал v на месте валидатора, он будет рассматривать его как объектную композицию, превращая его в валидатор объекта по его полям.


Иногда мы не можем описать все поля. Например, когда объект является словарём данных:


const quartet = require('quartet')
const v = quartet()

const isStringValidator = name => typeof name === 'string'

const keyValueValidator = (value, { key }) =>
  value.length === 1 && key.length === 1

const dictionarySchema= {
  dictionaryName: isStringValidator,
  ...v.rest(keyValueValidator)
}

const compositeObjValidator = v(dictionarySchema)

const obj = {
  dictionaryName: 'next letter',
  b: 'c',
  c: 'd'
}

const isObjValid = compositeObjValidator(obj)
console.log(isObjValid) // => true

const obj2 = {
  dictionaryName: 'next letter',
  b: 'a',
  a: 'invalid value',
  notValidKey: 'a'
}
const isObj2Valid = compositeObjValidator(obj2)
console.log(isObj2Valid) // => false

Как переиспользовать строительные решения?


Как мы увидели выше, существует необходимость переиспользования простых валидаторов. В данных примерах нам уже пришлось использовать "валидатор типа строки" уже два раза.


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


По умолчанию в библиотеке уже определены самые распространённые валидаторы.


Рассмотрим примеры:


v('number')(1) // => true
v('number')('1') // => false
v('string')('1') // => true
v('string')(null) // => false
v('null')(null) // => true
v('object')(null) // => true
v('object!')(null) // => false
// ...

и множество других описаных в документации.


Каждой арке — свой вид кирпичей?


Композитор валидаторов(функция v) также является фабрикой валидаторов. В том смысле, что содержит множество полезных методов, которые возвращают


  • валидаторы-функции
  • значения, которые композитором будут восприниматься как схемы для создания валидаторов

Например, посмотрим на валидацию массива: чаще всего она состоит из проверки типа массива и проверки всех его элементов. Воспользуемся для этого методом v.arrayOf(elementValidator). Для примера возьмём массив точек с именами.


  const a = [
    {x: 1, y: 1, name: 'A'},
    {x: 2, y: 1, name: 'B'},
    {x: -1, y: 2, name: 'C'},
    {x: 1, y: 3, name: 'D'},
  ]

Так как массив точек — это массив объектов, то имеет смысл использовать объектную композицию для валидации элементов массива.


const namedPointSchema = {
  x: 'number', // number - один из именованных по умолчанию валидаторов
  y: 'number',
  name: 'string' // string - один из именованных по умолчанию валидаторов
}

Теперь, с помощью фабричного метода v.arrayOf, создадим валидатор всего массива.


const isArrayValid = v.arrayOf({
  x: 'number',
  y: 'number',
  name: 'string'
})

Посмотрим как работает данный валидатор:


isArrayValid(0) // => false
isArrayValid(null) // => false
isArrayValid([]) // => true
isArrayValid([1, 2, 3]) // => false
isArrayValid([
    {x: 1, y: 1, name: 'A'},
    {x: 2, y: 1, name: 'B'},
    {x: -1, y: 2, name: 'C'},
    {x: 1, y: 3, name: 'D'},
  ]) // => true

Это только один из фабричных методов, каждый из которых описан в документации


Как вы видели выше, v.rest также является фабричным методом, который возвращает объектную композицию, которая проверяет все поля не указанные в объектной композиции. А значит, может быть встроен в другую объектную композицию с помощью spread-operator.


Приведём в качестве примера использования нескольких из них:


// Подключаем библиотеку валидации quartet
const quartet = require('quartet')
// Создаём композитор валидаторов (v - значит validator)
const v = quartet()
// Рассмотрим такой объект, который описывает персонажа
const max = {
  name: 'Maxim',
  sex: 'male',
  age: 34,
  status: 'grandpa',
  friends: [
    { name: 'Dima', friendDuration: '1 year'},
    { name: 'Semen', friendDuration: '3 months'}
  ],
  workExperience: 2
}
// имя валидно, когда является "и" строкой,
// "и" не пустой, "и" первая буква - большая
const nameSchema = v.and(
  'not-empty', 'string', // именованные валидаторы
  name => name[0].toUpperCase() === name[0] // валидатор-функция
)
const maxSchema  = {
   name: nameSchema,
  // Валидатор принадлежности к задданному множеству значений
  sex: v.enum('male', 'female'),
  // Возраст - положительное целое число.
  // Используем именнованые валидаторы и фабричный метод "и"
  age: v.and('non-negative', 'safe-integer'),
  status: v.enum('grandpa', 'non-grandpa'),
  friends: v.arrayOf({
    name: nameSchema,
    // валидируем строку по регулярному выражению
    friendDuration: v.regex(/^[1-9]\d? (years?|months?)$/)
  }),
  workExperience: v.and('non-negative', 'safe-integer')
}
console.log(v(maxSchema)(max)) // => true

Быть, или не быть?


Часто бывает так, что валидные данные принимают различные формы, например:


  • id может быть числом, а может быть строкой.
  • Объект point может содержать, а может не содержать некоторые координаты, в зависимости от размерности.
  • И множество других случаев.

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


Рассмотрим пример c валидацией идентификаторов:


  const isValidId = v([
   v.and('not-empty', 'string'), // Идентификатор может быть либо непустой строкой
   v.and('positive', 'safe-integer') // Либо положительным числом
 ])

 isValidId('') // => false
 isValidId('asdba32bas321ab321adb321abds546ba98s7') // => true
 isValidId(0) // => false
 isValidId(1) // => true
 isValidId(1123124) // => true

Пример с валидацией точек:


const isPointValid = v([

  {
   // для первой размерности - должна быть только x координата
   dimension: v.enum(1),
   x: 'number',
   // v.rest с функцией возвращающей false
   // Означает, что дополнительные поля - невалидны
   ...v.rest(() => false)
 },
 // для второй - х и у
 {
   dimension: v.enum(2),
   x: 'number',
   y: 'number',
   ...v.rest(() => false)
 },
 // Для третьей - x, y и z
 {
   dimension: v.enum(3),
   x: 'number',
   y: 'number',
   z: 'number',
   ...v.rest(() => false)
 },
])
// Итого, валидной точкой считается та, у которой размерность не выше третьей, и для каждой размерности - соответствующее кол-во полей для координат
isPointValid(1) // => false
isPointValid(null) // => false
isPointValid({
  dimension: 1,
  x: 2
}) // => true
isPointValid({
  dimension: 1,
  x: 2,
  y: 3 // лишнее поле
}) // => false
isPointValid({
  dimension: 2,
  x: 2,
  y: 3
}) // => true
isPointValid({
  dimension: 3,
  x: 2,
  y: 3,
  z: 4
}) // => true
// ...

Таким образом всякий раз, когда композитор видит массив, он будет считать его композицией валидаторов-элементов этого массива таким образом, что когда один из них посчитает значение валидным — расчёт валидации остановится — и значение будет признано валидным.


Как видим композитор считает валидатором не только функцию валидатор, но и всё, что может привести к функции валидатору.


Тип валидатора Пример Как воспринимается композитором
функция валидации x => typeof x === 'bigint' просто вызывается на необходимых значениях
объектная композиция { a: 'number' } создает функцию валидатор для объекта на основании заданных валидаторов полей
Вариантная композиция ['number', 'string'] Создаёт функцию валидатор для валидации значения минимум одним из вариантов
Результаты вызова фабричных методов v.enum('male', 'female') Большинство фабричных методов возвращают функции валидации (за исключением v.rest, который возвращает объектную композицию), поэтому они трактуются как обычные функции валидации

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


В итоге схема работы всегда такая: v(schema) возвращает функцию валидации. Далее эта функция валидации вызывается на конкретных значениях:
v(schema)(value[, ...parents])


У вас аварии на стройке были?


— Нет пока ещё не одной
— Будут!


Бывает так, что данные невалидны и нам нужно уметь определить причину невалидности.


Для этого в библиотеке quartet предусмотрен механизм объяснений. Он состоит в том, что в случае, когда валидатор, будь-то внутренний или внешний, обнаружит невалидность проверяемых данных — он должен отправить пояснительную записку.


Для этих целей используется второй аргумент композитора валидаторов v. Он добавляет сайд-еффект отправки пояснительной записки в массив v.explanation в случае невалидности данных.


Пример, пусть мы валидируем массив, и хотим узнать номера всех элементов, которые невалидны и их значение:


  // Данная функция - будет вызвана при невалидности
 // элемента массива
 const getExplanation = (value, { key: index }) => ({
   invalidValue: value,
   index
 })
 // Видим, что её параметры совпадают с параметрами валидаторов.
 // Результат же этой функции будет помещён в массив v.explanation

 // Зададим валидатор массива
const arrValidator = v.arrayOf(
  v(
    'number', // валидатор числа
    getExplanation // функция возвращающая "записку", или сама "записка"
  )
)
// видим, что валидатором элемента является "объясняющий" валидатор
// Вторым параметром композитора является функция, которая возвращает объяснение ошибки

// Вторым параметром композитора может быть не только функция
// Но и значение, которое должно быть помещено как объяснение
const explainableArrValidator = v(arrValidator, 'this array is not valid')
const arr = [1, 2, 3, 4, '5', '6', 7, '8']
explainableArrValidator(arr) // => false
v.explanation
// [
//  { invalidValue: '5', index: 4 },
//  { invalidValue: '6', index: 5 },
//  { invalidValue: '8', index: 7 },
//  'this array is not valid'
// ]

Как видим, выбор объяснения зависит от задачи. Иногда оно даже не нужно.


Иногда нам необходимо что-то сделать с невалидными полями. В таких случаях имеет смысл использовать имя невалидного поля как объяснение:


const objSchema = {
  a: v('number', 'a'),
  b: v('number', 'b'),
  c: v('string', 'c')
}
const isObjValid = v(objSchema)
let invalidObj = {
 a: 1,
 b: '1',
 c: 3
}
isObjValid(invalidObj) // => false
v.explanation // ['b', 'c']
// Сообщаем о невалидных полях
console.error(`${v.explanation.join(', ')} is not valid`) // => b, c is not valid
// Удаляем невалидные и не проверенные поля (см. документацию)
invalidObj = v.omitInvalidProps(objSchema)(invalidObj)
console.log(invalidObj) // => { a: 1 }

Имея данный механизм объяснений, можно реализовать любое поведение, связанное с результатами валидации.


Пояснением может быть всё что угодно:


  • объект содержащий необходимую информацию;
  • функция, которая исправляет ошибку. (getExplanation => function(invalid): valid);
  • имя невалидного поля, или индекс невалидного элемента;
  • код ошибки;
  • и всё на что хватит вашей фантазии.

Что делать, когда дело не строится?


Исправлять ошибки валидации — не редкая задача. Для этих целей в библиотеке используются валидаторы с побочным эффектом, который запоминает место ошибки и как её исправить.


  • v.default(validator, value) — возвращает валидатор, который запоминает невалидное значение, и в момент вызова v.fix — устанавливает дефолтное значение
  • v.filter(validator) — возвращает валидатор, который запоминает невалидное значение, и в момент вызова v.fix — удаляет это значение из родителя
  • v.addFix(validator, fixFunc) — возвращает валидатор, который запоминает невалидное значение, и в момент вызова v.fix — вызывает fixFunc c параметрами (value, { key, parent }, ...). fixFunc — должна муттировать одного из парентов — для изменения значения

const toPositive = (negativeValue, { key, parent }) => {
  parent[key] = -negativeValue
}

const objSchema = {
  a: v.default('number', 1),
  b: v.filter('string', ''),
  c: v.default('array', []),
  d: v.default('number', invalidValue => Number(invalidValue)), // привести к числу
  pos: v.and(
     v.default('number', 0), // Если значение не число - установить 0
     v.addFix('non-negative', toPositive) // если значение не положительно - поменять знак
  )
 }
 const invalidObj = {
  a: 1,
  b: 2,
  c: 3,
  d: '4',
  pos: -3
 }
v.resetExplanation() // или синоним v()
v(objSchema)(invalidObj) // => false
 // v.hasFixes() => true
 const validObj = v.fix(invalidObj)
 console.log(validObj) // => { a: 1, b: '', c: [], d: 4 }

По хозяйству ещё пригодиться


В данной библиотеке также существуют утилитные методы для действий связанных с валидацией:


Метод Результат
v.throwError В случае невалидности бросает TypeError с заданным сообщением.
v.omitInvalidItems Возвращает новый массив(или объект-словарь) без невалидных элементов(полей).
v.omitInvalidProps Возвращает новый объект без невалидных полей, по заданному объектному валидатору.
v.validOr Возвращает значение, если оно валидно, иначе заменяет его на заданное дефолтное значение.

Результаты


Заданные задачи были решены следующими способами:


Задача Решение
Валидация типов данных Дефолтные именованные валидаторы.
Дефолтные значения v.default
Удаление невалидных частей v.filter, v.omitInvalidItems и v.omitInvalidProps.
Легкость в освоении Простые валидаторы, простые способы композиции их в сложные валидаторы.
Читабельность кода Одной из целей библиотеки была уподобить схемы валидаций самим
валидируемым объектам.
Легкость модификации Освоив элементы композиций и используя собственные функции валидации — менять код довольно просто.
Сообщение об ошибке Пояснение, в виде сообщения об ошибки. Или расчёт кода ошибки на основе пояснений.

Послесловие


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

Комментарии (6)


  1. burfee
    15.11.2018 15:24

    Привет! Мне кажется чет сложновато получилось, хотя уверен у вас были свои причины на это. Недавно тоже думали на эту тему и тоже написали свое: unmanner.github.io/travajs


    1. kila_vat Автор
      15.11.2018 17:38

      Сама задача не тривиальна — простых решений тут немного.

      Хотелось бы узнать, какие моменты показались сложными — возможно я не вижу каких-то более легких решений, если бы мне указали на них — был бы рад.


      1. burfee
        15.11.2018 17:59

        Честно говоря мне кажется что задача тривиальна.
        Было все просто: значение => isvalid?. А дальше стало сложно.
        Для функций замечательно подходит функциональная композиция, не понятно зачем было притягивать объектную. Тогда не пришлось бы делать странные вещи вроде v.and.
        v.explanation получается хранит глобальное состояние, это всегда боль. Не нашел удобным запись валидатора «снизу-вверх», мне кажется более логичным описывать «сверху-вниз» от родителя к потомкам.

        Я указал решение, которое считаю простым. Еще из популярных есть joi


        1. kila_vat Автор
          15.11.2018 20:18

          Честно говоря мне кажется что задача тривиальна.


          Возможно, вы и правы


          Для функций замечательно подходит функциональная композиция


          Абсолютно согласен


          не понятно зачем было притягивать объектную.


          Это синтаксический сахар. То что можем записать так:


          const objectValidator = Trava.Keys({
            a: Trava.Check(a => a >= 0),
            b: Trava.Keys({
              innerA: Trava.Check(a => a >= 0),
              innerB: Trava.Required(Trava.Check(b => b.startsWith('nice'))),
            })
          });

          Можем записать и так:


          const objectValidator = v({
            a: ['undefined', 'non-negative'],
            b: {
              innerA: ['undefined', 'non-negative'],
              innerB: v.and('string', b => b.startsWith('nice')),
            }
          });

          Тогда не пришлось бы делать странные вещи вроде v.and.


          Вы имеете в виду странность нейминга? Просто, вроде как, это та же функция, что и travajs/Compose.


          v.explanation получается хранит глобальное состояние, это всегда боль.


          Цель этой вещи собирать объяснения. Если нам понадобится их хранить — то они хранятся, если же нет — мы перед очередной проверки — очищаем этот список, с помощью v.resetExplanation() или короче v().


          Обработав ошибки валидации — объяснения врядли нам нужны — поэтому то, что они будут стёрты перед следующей валидацией — не составляет проблемы.


          Сейчас работает так, что не только композитор, но и сам валидатор — сам хранит объяснение, если таковое есть.


            const isValidNumber = v('number', v => `'${v}' is not a number`)
            const isValidString = v('string', v => `'${v}' is not a string`)
          
            isValidNumber('not valid')
            isValidString(123)
          
            console.log(v.explanation) // => [`'not valid' is not a number`]
            console.log(isValidNumber.explanation) // => [`'not valid' is not a number`]
            console.log(isValidString.explanation) // => [`'123' is not a string`]

          Так что нет ничего удивительного в том, что глобальный объект хранит глобальные объяснения, а локальные валидаторы — хранят свои объяснения. Но чаще всего просто легче использовать глобальное хранилище — в виду того, что туда идут все объяснения.


          Не нашел удобным запись валидатора «снизу-вверх», мне кажется более логичным описывать «сверху-вниз» от родителя к потомкам.


          Чаще всего так и будет и это приветствуется, дополнительные параметры — лишь добавляет некоторую гибкость:


            // данную взаимосвязь можно провалидировать по разному
            const row = { x: 3, squareX: 9 }
          
            // сверху-вниз
           const isRowValid = v(
             {
              x: 'number',
              squareX: v.and('non-negative', 'number')
             }
             ({ x, squareX }) => x ** 2 === squareX
           )
          
          // снизу-вверх
           const isRowValid = v(
             {
              x: 'number',
              squareX: v.and(
                'non-negative', 'number',
                (squareX, { parent }) => parent.x ** 2 === squareX // или v.parent(({ x, squareX }) => x ** 2 === squareX)
             }
           )


          1. burfee
            15.11.2018 21:22

            Можем записать и так:

            Я подумал, добавлю такой же сахар :) разница здесь пожалуй только в том, что у вас дополнительный уровень косвенности в виде именованных валидаторов.

            Вы имеете в виду странность нейминга? Просто, вроде как, это та же функция, что и travajs/Compose.

            Да, тут я не прав.

            На счет состояния не могу согласиться, любое состояние — это лишние проблемы. Если можно обойтись без этого, то нужно так и делать. Тем более концепция валидаторов идеально ложится на чистые функции без состояний. Странно не пользоваться этим замечательным фактом.

            Чаще всего так и будет и это приветствуется, дополнительные параметры — лишь добавляет некоторую гибкость:

            А зачем нужна такая гибкость? Я просто убежденный сторонник подхода «если не надо — выпиливаем» и «есть только один правильный способ сделать правильно», который меня уже много лет выручает.


            1. kila_vat Автор
              15.11.2018 23:52

              На счет состояния не могу согласиться


              Я тоже не люблю состояние и предпочитаю чистые функции без побочных эффектов.


              Но был выбор между двумя вариантами:


              • Валидатор возвращает объект с полным описанием проблемы(на подобие ValidationError), или null.
              • Валидатор возвращает true или false.

              У первого решение — есть большие преимущества в виде фактической чистоты функций, отсутствия состояние и прочее и прочее.


              У второго же варианта — есть минусы, такие как состояние, которое нужно где-то хранить, чтобы были возможны сообщения об ошибках и исправления.


              Но мне валидация не выдаётся чем-то-то разтянутым по времени и месту. А значит что и хранимое состояние, и глобальные объекты — редко когда будут переживать по времени жизни — одну валидацию.


              Приведу пару аргументов, почему я выбрал второй вариант:
              1) Валидаторы можно использовать как предикаты без декораторов. Представим, что нам нужно свалидировать свойство во Vue:


              export default {
                props: {
                  messages: {
                    type: Array,
                    validator: v.arrayOf({ // Очень даже читаемо
                      author: v.and('not-empty', 'string'),
                      date: date => date instanceof Date,
                      text: v.and('not-empty', 'string')
                    })
                  }
                }
              // ...
              }

              Или использование в качестве аргумента в методы массива:


              const arr = [1,2,3,'4','5','6']
              const numbers = arr.filter(v('number'))

              2) Происходит некоторое разделение обязанностей: валидатор — валидирует, объяснения объясняют, исправления исправляют


              const instanceOf = constr => value => value instanceof constr
              const isValidMessage = v({
                author: v(
                  v.default(v.and('not-empty', 'string'), 'unknown'),
                  'author name is not valid'
                ),
                date: v(
                  v.default(instanceOf(Date), new Date()),
                  'date is not a date'
                ),
                message: v(
                  v.default('string', ''),
                  'message is not a string'
                )
              })
              try {
                let message = await apiRequest('someApiUrl')
                v() // resetExplanation
                if (!isValidMessage(message)) { // true or false
                  this.logError(new Error(v.explanation.join(', '))) // log error explanation
                  message = v.fix(message) // invalidObj => validObj
                }
                return message
              }

              C таким разделением функционала на части — код наглядно демонстрирует последовательность:


              • валидация
              • если не валидно — лог ошибки, и исправление
              • возвращение валидного результата

              Я согласен, что возможно это не наилучшее решение — возможно его можно сделать ещё чище(в смысле меньшего кол-во глобальных вещей и чистоты функций). Подумаю над этим.


              А зачем нужна такая гибкость? Я просто убежденный сторонник подхода «если не надо — выпиливаем» и «есть только один правильный способ сделать правильно», который меня уже много лет выручает.


              Если не надо — выпиливаем — абсолютно согласен с этим подходом, равно как и с фразой есть только один правильный способ сделать это.


              Дополнительный взгляд вверх по иерархии проверяемого объекта — вдохновлён отчасти методами массивов — которые позволяют при фильтрации — смотреть на значение массива. Например можно сделать валидатор, который по разному валидирует разные элементы массива: например элементы массива с индексами, которые являются простыми числами — должны быть простыми и тд.


              Но такие случаи редки, и может даже не имеет смысла уделять им столько внимания. Чаще это всё же используется для организации разного рода дополнительного поведения: например, это используется методами исправляющими ошибки. В момент валидации валидаторы декорируемые методами-исправлениями (v.default, v.filterи v.addFix) строят дерево исправлений — там-то и используется путь от проверяемого объекта до исправляемого свойства.


              Это дерево позже используется при вызове v.fix.


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