The True Power Of Monoids: How To Combine Validators
In a previous article, I introduced Checkoid, a validation library with powerful validator combinations. This article is the first part in a series of three, where I will use the checkoid internals as an example to illustrate several functional programming techniques.
- Part 1: The True Power Of Monoids: How To Combine Validators (this article)
- Part 2: Validator Assemble! Functor in action
- Part 3: The almighty power of lazyness: How to concat async validator
Today, we will see how to concatenate validator functions thanks to monoids.
A Quick Reminder About Monoids
I already talked about Monoids in a previous article about functional programming. Here is a reminder.
A monoid is a combination of:
- a set of values (like all numbers, all strings, all arrays) with
- a
concat
operation that takes two values from the set and always returns a value of the same set (like addition for numbers, concatenation for strings) - The
concat
operation must be associative ega.concat(b.concat(c)) === (a.concat(b)).concat(c)
- a neutral element, which is a value from the set that, when combined with another set value, does not change it (0 for number addition, 1 for number multiplication,
''
for string concatenation)
To better work with monoids, we can create a factory function that takes a value of the set and wraps it in an object providing the concat
method.
Here is an example for the monoid composed of the boolean set and the and
operation:
const And = x => ({
x,
concat: other => x && other.x,
});
And.neutral = And(true);
You can use it as follows:
And(true).concat(And(true)).x; // => true
And(true).concat(And(false)).x; // => false
Quiet a convoluted way to do a && b
I know, but please bear with me.
Using The "And" Monoid to Create A Validator Library
In the previous article on monoids, I described one way to concatenate functions: composition. But there is another way.
What if we took the set of all functions returning our And
Monoid?
We can concatenate the And
, so we could create a concat
operation for the function returning And
(or any other monoid in fact). We'll call that new monoid Validator
:
// the run argument must be a function returning an And
const Validator = run => ({
run,
concat: other => {
const newRun = value => {
const validation = run(value);
const otherValidation = other.run(value);
return validation.concat(otherValidation);
};
return Validator(newRun);
},
});
Validator.neutral = Validator(() => And.neutral);
The following example usage explains why I called it Validator
:
const isGreaterThanThree = Validator(value => {
if (value > 3) {
return And(true);
}
return And(false);
});
const isSmallerThanTen = Validator(value => {
if (value < 10) {
return And(true);
}
return And(false);
});
const isBetweenThreeAndTen = isGreaterThanThree.concat(isSmallerThanTen);
isBetweenThreeAndTen.run(11); // And(false)
isBetweenThreeAndTen.run(2); // And(false)
isBetweenThreeAndTen.run(5); // And(true)
This simple monoid is already a flexible validator library, allowing us to concatenate validator functions.
But having just a boolean as a result is underwhelming for a validator library.
Let's Add A Validation Message
Let's create a new monoid data type for validation. It will hold nothing if valid, and a list of error messages if invalid.
The trick here is to use two factory functions for the same monoid data type, one Valid
and one Invalid
. And I don't know you, but I think that the name concat
is too generic, so let's rename it to and
.
Valid holds no value. It is just valid: No other information is required.
const Valid = () => ({
isValid: true,
// if other is valid return a Valid (Valid && Valid =>> Valid)
// if other is Invalid return it (Valid && Invalid =>> Invalid)
and: other => {
if (other.isValid) {
return Valid();
}
return other;
},
// add a getResult helper to get the nested value here nothing
// this allows to get the value for all Monoid member
getResult: () => undefined,
});
Invalid holds the validation errors. It takes an array of invalid results, so that we can concatenate them with ease. Did you know the array is also called the free monoid? Put anything in it, and you can concatenate.
const Invalid = invalidResults => ({
invalidResults,
isValid: false,
and: other => {
// if other is valid return an Invalid with invalidResults
// (Invalid && Valid =>> Invalid)
if (other.isValid) {
return Invalid(invalidResults);
}
// if other is Invalid too, concatenate both their invalidResults.
// And pass them to a new Invalid (Invalid && Invalid =>> Invalid)
const combinedInvalidResults = invalidResults.concat(
other.invalidResults,
);
return Invalid(combinedInvalidResults);
},
getResult: () => invalidResults,
});
Valid and Invalid act as two sub types of the Validation Type.
const Validation = { Valid, Invalid };
Now, let's use this data type in the Validator monoid:
// the run argument must be a function returning a Validation
const Validator = run => ({
run,
and: other => {
const newRun = value => {
const validation = run(value);
const otherValidation = other.run(value);
return validation.and(otherValidation);
};
return Validator(newRun);
},
// let's add a check helper to get the value inside the validation directly
check(value) => run(value).getResults();
});
Now we can specify validator funtions returning exlicit error messages:
// let's be more generic
const isGreaterThan = max =>
Validator(value => {
if (value > max) {
return Validation.Valid();
}
return Validation.Invalid([`value must be greater than ${max}`]);
});
const isSmallerThan = min =>
Validator(value => {
if (value < min) {
return Validation.Valid(true);
}
return Validation.Invalid([`value must be less than ${min}`]);
});
const isBetweenThreeAndTen = isGreaterThan(3).and(isSmallerThan(10));
isBetweenThreeAndTen.check(11); // ['value must be less than 10']
isBetweenThreeAndTen.check(2); // ['value must be greater than 3']
isBetweenThreeAndTen.check(5); // undefined
Not bad! But I think that creating a validator is rather verbose.
Hiding the Boilerplate
Ideally, we want to just create simple validator functions, without having to handle the validation object directly.
To do that we need a helper:
// validator takes a function that returns either
// - undefined if no error
// - a string describing the issue if there is an error
const validator = fn => {
return Validator(value => {
const result = fn(value);
// test the result to return either Valid or Invalid
if (result === undefined) {
return Valid();
}
return Invalid([result]);
});
};
With that helper, the previous example becomes:
const isGreaterThan = max =>
validator(value => {
if (value > max) {
return;
}
return `value must be greater than ${max}`;
});
const isSmallerThan = min =>
validator(value => {
if (value < min) {
return;
}
return `value must be less than ${min}`;
});
const isBetweenThreeAndTen = isGreaterThan(3).and(isSmallerThan(10));
isBetweenThreeAndTen.check(11); // ['value must be less than 10']
isBetweenThreeAndTen.check(2); // ['value must be greater than 3']
isBetweenThreeAndTen.check(5); // undefined
That's better! Now, how do I test if a value passes at least one validator from a list? Something like validatorA || validatorB
.
Introducing The Semiring
That's right, we need an or
operation. So how do we do that? Should we add another monoid data type called OrValidation
? No, the set is the same.
We actually want to add a second concat
operation: or
. So our monoid should have two concat
operations.
Two monoids sharing the same set form what is called a semiring. The term comes from category theory in mathematics. Well, to be accurate, we also need :
- commutativity on the first operation.
- That the neutral element of the first operation is a destructive element for the second operation. A destructive element, is an element that is the result of every operation in which it is an argument.
- Distributivity (left and right) of the first operation over the second one.
Here is an example semiring, with integer addition and multiplication:
- addition on number is a monoid with neutral element 0
- multiplication on number is a monoid with neutral element 1
- addition is commutative:
a + b === b + a
- addition neutral element (0) is destructive on multiplication:
a * 0 => 0
- left distributivity on multiplication over addition:
a * (b + c) === (a * b) + (a * c)
- right distributivity on multiplication over addition:
(a + b) * c === (a * c) + (c * c)
And for our case: boolean with &&
and ||
||
on boolean is a monoid with neutral elementfalse
&&
on boolean is a monoid with neutral elementtrue
||
is commutative:a || b === b || a
||
neutral element (false
) is destructive on&&
:a && false => false
&&
distribute from left over||
:a && (b || c) === (a && b) || (a && c)
&&
distribute from right over||
:(a || b) && c === (a && c) || (b && c)
That's enough mathematics for today!
Implementing Validation Combinations With An Or
So I need to update the Valid
and Invalid
data types to add the or
concat, and to do the same for the Validator
monoid.
const Valid = () => ({
x,
isValid: true,
and: other => {
if (other.isValid) {
return Valid();
}
return other;
},
// whether the other is valid or not return a Valid (Valid || whatever =>> Valid)
or: other => Valid(),
getResult: () => undefined,
});
const Invalid = invalidResults => ({
invalidResults,
isValid: true,
and: other => {
if (other.isValid) {
return Invalid(invalidResults);
}
const combinedInvalidResults = invalidResults.concat(
other.invalidResults,
);
return Invalid(combinedInvalidResults);
},
// if the other is valid return it (Invalid || Valid =>> Valid)
// if other is Invalid concat their value into a new Invalid
// (Invalid || Invalid =>> Invalid)
or: other => {
if (other.isValid) {
return Valid();
}
const combinedInvalidResults = invalidResults.concat(
other.invalidResults,
);
return Invalid(combinedInvalidResults);
},
getResult: () => invalidResults,
});
const Validation = { Valid, Invalid };
// and for validator, simply pass the operation along:
const Validator = run => ({
run,
and: other => {
const newRun = value => {
const validation = run(value);
const otherValidation = other.run(value);
return validation.and(otherValidation);
};
return Validator(newRun);
},
or: other => {
const newRun = value => {
const validation = run(value);
const otherValidation = other.run(value);
return validation.or(otherValidation);
};
return Validator(newRun);
},
check(value) => run(value).getResult();
});
// the validator helper does not need to change
Now I can combine validators using both and
and or
:
const isNull = validator(value => {
if (value === null) {
return;
}
return 'Value can be null';
});
const isOptionalNumberBetweenThreeAndTen = isBetweenThreeAndTen.or(isNull);
isOptionalNumberBetweenThreeAndTen.check(null); // undefined
isOptionalNumberBetweenThreeAndTen.check(5); // undefined
isOptionalNumberBetweenThreeAndTen.check(0);
// ['value must be greater than 3', 'Value can be null']
That's it, now we can combine validator like lego pieces.
Conclusion
If you put aside the arcane vocabulary, functional programming concepts like the monoid can be used in a relatively simple way to implement flexible and powerful libraries.
If you like my Validator
function, check the validation library that I built on this principle: marmelab/checkoid. It's open-source, feel free to use it and contribute back!
In the next article in this series, I will explain how to create an object validator by combining other validators, while adding context.