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