The True Power Of Monoids: How To Combine Validators

Thiery Michel
Thiery MichelJanuary 21, 2021
#functional-programming

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.

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 eg a.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 element false
  • && on boolean is a monoid with neutral element true
  • || 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.

Did you like this article? Share it!