Applicative validation in JavaScript

November 10, 2014

Applicative functors underlie a handy way to perform functional validation, even in a dynamically-typed language like JavaScript.

The basic idea is to take a function like this:

function user(name, email, dob) {
  return {
      name: name
    , email: email
    , dob: dob
  };
}

And connect it to three validated values (name, email, dob) that have been provided by the user. A validated value is exactly one of two things:

  1. A structure, called valid, containing a valid value
  2. A structure, called invalid, containing a list of errors

We can implement these structures as functions:

function valid(value) {
  return {
      valid:    true
    , value:    value
  };
}

function invalid(errors) {
  return {
      valid:    false
    , errors:   errors
  };
}

Now we have a problem, because the types of our validated values don't match those expected by our user function. We need name, email, and dob to be strings, but after validating these values we end up with valid or invalid instances for each of them.

To bridge the gap between our pure user function and our validated values, we need an applicative functor.

Recall that an applicative functor allows us to apply a lifted function, F[A => B] to a lifted value, F[A], to get a lifted result, F[B], for some context F:

ap :: F[A => B] => F[A] => F[B]

In this case, our context is validation:

ap :: Validation[A => B] => Validation[A] => Validation[B]

Let's implement ap for our validation structures:

function valid(value) {
  return {
      valid:    true
    , value:    value
    , map:      function(f) { return valid(f(value)); }
    , ap:       function(a) { return a.map(value); }
  };
}

For valid values, ap maps our lifted function value onto a lifted value a.

function invalid(errors) {
  return {
      valid:    false
    , errors:   errors
    , map:      function(f) { return this; }
    , ap:       function(a) {
                  if (a.valid) { return this; }
                  else { return invalid(this.errors.concat(a.errors)); }
                }
  };
}

For invalid values, there is no function for which to apply lifted values, so we do nothing when applying a valid value, and accumulate the errors when applying an invalid value.

We'll need to tweak the user function a bit to make this all fit together. As written, it's a single function of three arguments, but we'll need it to be a curried function of three arguments instead. Let's rewrite it as a series of unary functions:

function user(name) {
  return function (email) {
    return function (dob) {
      return {
          name: name
        , email: email
        , dob: dob
      };
    };
  };
}

This way we can call user with single-argument lists:

var myUser = user('Joe')('foo@bar')('11/22/3333')

Now, when we lift our curried user into a validation result via valid(user), we can call its ap function, pass our name validation result, and get back a validation result of the rest of the curried user function:

var myUserV = valid(user).ap(valid('Joe')).ap(valid('foo@bar')).ap(valid('11/22/3333'))

If everything is valid, the result will be the data structure produced by valid, wherein value is the user data structure.

If anything is invalid, the result will instead be the data structure produced by invalid, and its errors field will be populated with a list of every value that failed to validate.

Let's look at some examples. For clarity, we'll suffix values that we expect to be valid with V, and values that we expect to be invalid with I:

var nameV  = nonempty('name', 'Joe');
var emailV = userAtDomain('email', 'foo@bar');
var dobV   = mmddyyyy('dob', '11/22/3333');

console.log(nameV.toString()); // valid(Joe)
console.log(emailV.toString()); // valid(foo@bar)
console.log(dobV.toString()); // valid(11/22/3333)

var nameI  = nonempty('name', '');
var emailI = userAtDomain('email', null);
var dobI   = mmddyyyy('dob', '11/22/33');

console.log(nameI.toString()); // invalid(name must not be empty)
console.log(emailI.toString()); // invalid(email must be formatted as user@domain)
console.log(dobI.toString()); // invalid(dob must be formatted as mm/dd/yyyy)

var userV = valid(user).ap(nameV).ap(emailV).ap(dobV);
console.log(userV.toString()); // valid(user(name: Joe, email: foo@bar, dob:11/22/3333)

var userI = valid(user).ap(nameI).ap(emailI).ap(dobI);
console.log(userI.toString()); // invalid(
                               //     name must not be empty
                               //   , email must be formatted as user@domain
                               //   , dob must be formatted as mm/dd/yyyy
                               // )

With our valid (or invalid) results, we can proceed to process the valid data, or inform the user of the validation errors all at once.

Demo