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