Introducing Checkoid, An Input Validation Library Built With Composition In Mind

Thiery Michel
Thiery MichelNovember 13, 2020
#js#oss

I've built a simple and powerful validation library that allows to combine validator like lego pieces. It's called checkoid, it's open-source, and it will help make your validation code more reusable, more testable, and more expressive.

Motivation

We often need to validate values, be it client-side (on the form input) or server-side (on the request body). We also often need to reuse the same validation logic over and over again.

For instance, you may have several functions to validate a password, an email, a name, and an address. You may also need to validate a User object, containing all these fields. So you need to combine the field validators into a single function, which returns the full report of all invalid properties.

Yet, whether you're validating a single string or a complex object containing an array of objects, the principle of validation is always the same: If the input is invalid, return a list of all problems ; If the input is valid, return nothing.

Since the logic is always the same, we should be able to combine validators automatically.

Introducing Checkoid

combining lego piece

Checkoid allows us to do just that: Take simple validators, and combine them into a more complex one.

Here is an example:

import { validator } = from 'checkoid';

// create individual validators
const isEmail = validator((value) => {
    if (/@/.test(value)) {
        return; // empty return means valid input
    }
    return 'value must be an email';
});
const isNotGmail = validator((value) => {
    if (/gmail.com/.test(value)) {
        return 'value must not be a gmail adress';
    }
});

// combine the two validators into one
const isEmailNotFromGMail = isEMail.and(isNotGmail);

// check inputs
isEmailNotFromGMail.check('whatever');
// [
//    { message: 'value must be an email', value: 'whatever' },
//    { message: 'value must not be a gmail adress', value: 'test@gmail.com' }
// ]
isEmailNotFromGMail.check('test@gmail.com');
// [{ message: 'value must not be a gmail adress', value: 'test@gmail.com' }]
isEmailNotFromGMail.check('test@free.fr'); // undefined

Creating a Validator

The simplest way to represent a validation is a function that takes an input, and returns nothing if the input is valid, or an error message if the input is invalid. For instance:

const isEmail = (value) => {
    if (/@/.test(value)) {
        return; // empty return means valid input
    }
    return 'value must be an email';
};
const isNotGmail = (value) => {
    if (/gmail.com/.test(value)) {
        return 'value must not be a gmail adress';
    }
};

isEmail('test@gmail.com'); // undefined
isNotGmail('test@gmail.com');
// 'value must not be a gmail adress'
isEmail('whatever');
// 'value must be an email'
isNotGmail('whatever');
// 'value must not be a gmail adress'

To allow to combine such validation functions, Checkoid introduces the validator function. It wraps validation functions, and returns an object with a check method to call the validation function:

import { validator } = from 'checkoid';

const isEmail = validator((value) => {
    if (/@/.test(value)) {
        return; // empty return means valid input
    }
    return 'value must be an email';
});
const isNotGmail = validator((value) => {
    if (/gmail.com/.test(value)) {
        return 'value must not be a gmail adress';
    }
});

isEmail.check('test@gmail.com'); // undefined
isNotGmail.check('test@gmail.com');
// [{ message: 'value must not be a gmail adress', value: 'test@gmail.com' }]
isEmail.check('whatever');
// [{ message: 'value must be an email', value: 'whatever' }]
isNotGmail.check('whatever');
// [{ message: 'value must not be a gmail adress', value: 'whatever' }]

Combining Validators

The validator function returns a Validator object. This Validator object can be combined with other Validators in multiple ways.

You can combine validators with and:

const isEmailNotFromGMail = isEMail.and(isNotGmail);
isEmailNotFromGMail.check('whatever');
// [
//    { message: 'value must be an email', value: 'whatever' },
//    { message: 'value must not be a gmail adress', value: 'test@gmail.com' }
// ]
isEmailNotFromGMail.check('test@gmail.com');
// [{ message: 'value must not be a gmail adress', value: 'test@gmail.com' }]
isEmailNotFromGMail.check('test@free.fr'); // undefined

Or with or

const isEmpty = validator(value => {
    if (!!value) {
        return 'value is not empty';
    }
});

const isOptionalEmail = isEmail.or(isEmpty);

isOptionalEmail.check(''); // undefined
isOptionalEmail.check('test@gmail.com'); // undefined
isOptionalEmail.check('invalid mail');
// [
//     { message: 'value must be an email', value: 'invalid mail' },
//     { message: ''value is not empty'', value: 'invalid mail' }
// ]

Validating Arrays And Objects

You can validate a list of values by using the arrayOf validator:

import { arrayOf } from 'checkoid';

// listValidator takes any validator and applies it to a list of value
const isEmailList = arrayOf(isEmail);

isEmailList.check([]); // undefined
isEmailList.check(['test@test.com', 'john@doe.com']); // undefined
isEmailList.check(['test@test.com', 'I am a valid email', 'john@doe.com']);
// [{ key: [1], message: 'value must be an email', value: 'I am a valid email' }]
isEmailList.check('I am an email list'); // [{ message: 'value must be an array', value: 'I am an email list' }]

Similarly, you can validate an object based on field validators by using the shape combinator:

import { shape } = from 'checkoid';

const isGreaterThan = length => validator(value => {
    if (value && value.length <= length) {
        return `value must be at least ${length} characters long`;
    }
})

// objectValidator takes an object of other validator and returns a validator
const validateUser = shape({
    email: isEmail.or(isAbsent),
    password: isGreaterThan(8),
});

validateUser.check({ email: 'john@gmail.com', password: 'shouldnotdisplaythis' }) // undefined
validateUser.check({ email: 'john@gmail.com', password: 'secret' })
// [{ key: ['password'], message: 'value must be at least 8 characters long', value: 'secret' }]
validateUser.check('Hi I am John a valid user')
// [{ message: 'value must be an object', value: 'Hi I am John a valid user' }]

And of course, you can validate an array of objects!

import { arrayOf } from 'checkoid';
const isUserList = arrayOf(validateUser);

isUserList.check([]); // undefined
isUserList.check([
    { email: 'john@gmail.com', password: 'shouldnotdisplaythis' },
    { email: 'jane@gmail.com', password: 'mySecretPassword' },
]); // undefined
isUserList.check([
    { email: 'john@gmail.com', password: 'shouldnotdisplaythis' },
    'I am an user',
    { email: 'jane@gmail.com', password: '1234' },
]);
// [
//    { key: [1], mesage: 'value is not an object', value: 'I am an user' },
//    { key: [2, 'password'], message: 'value must be at least 8 characters long', value: '1234' },
// ]

In short, you can combine all Validators, and you will always get back a Validator.

Async Validators

You can also create asynchronous validators, e.g. relying on an API for validation. This can be useful e.g. to prevent the creation of two user accounts for the same email address:

import { asyncValidator } = from 'checkoid';

const doesUserIdExists = asyncValidator(async value => {
    const user = await fetchUser(value);
    if (user) {
        return;
    }

    return 'There is no user with this id';
});

// with an async validator the check method return a promise
await doesUserIdExists.check('badId');
// [{ message: 'There is no user with this id', value: 'badId' }]
await doesUserIdExists.check('goodId'); // undefined'

You can combine Asynchronous validators exactly like synchronous ones. You can even combine them with synchronous Validators. As soon as you combine an asynchronous validator with a synchronous one, the resultant validator will automatically become asynchronous.

Demo

Test checkoid in action in this CodeSandbox:

Going Further

Do you want to give it a try? Go to marmelab/checkoid on GitHub, clone the repository, and you'll be ready to go.

You can also install checkoid from npm:

npm install checkoid

Maybe you wonder how checkoid works? I'll explain that in another article. Spoiler alert: I will talk about the monoid and combinator patterns. If you want to learn more about them, check the series of blog posts I wrote on functional programming!

Did you like this article? Share it!