javascriptvalidationfunctional-programmingfolktale

Nested Validations With Folktale


I've been using Folktale's Validation on a new project and I've found it really useful, but I have hit a wall with the need for sequential validations. I have a config object and I need to perform the following validations:

Each validation depends on the previous validation - if the item isn't an object, validating its keys is pointless (and will error), if the object has no keys, validating their values are pointless. Effectively I want to short-circuit validation if the validation fails.

My initial thought was to use Result instead of Validatio, but mixing the two types feels confusing, and I already havevalidateIsObject` defined and used elsewhere.

My current (working but ugly) solution is here:

import { validation } from 'folktale';
import { validateIsObject } from 'folktale-validations';
import validateConfigKeys from './validateConfigKeys';
import validateConfigValues from './validateConfigValues';

const { Success, Failure } = validation;

export default config => {
  const wasObject = validateIsObject(config);
  let errorMessages;
  if (Success.hasInstance(wasObject)) {
    const hadValidKeys = validateConfigKeys(config);
    if (Success.hasInstance(hadValidKeys)) {
      const hasValidValues = validateConfigValues(config);
      if (Success.hasInstance(hasValidValues)) {
        return Success(config);
      }
      errorMessages = hasValidValues.value;
    } else {
      errorMessages = hadValidKeys.value;
    }
  } else {
    errorMessages = wasObject.value;
  }
  return Failure(errorMessages);
};

I initially took the approach of using nested matchWiths, but this was even harder to read.

How can I improve on this solution?


Solution

  • You can write a helper that applies validation rules until a Failure is returned. A quick example:

    const validateUntilFailure = (rules) => (x) => rules.reduce(
      (result, rule) => Success.hasInstance(result) 
        ? result.concat(rule(x)) 
        : result,
      Success()
    );
    

    We use concat to combine two results. We use Success.hasInstance to check whether we need to apply the next rule. Your module will now be one line long:

    export default config => validateUntilFailure([ 
      validateIsObject, validateConfigKeys, validateConfigValues
    ]);
    

    Note that this implementation doesn't return early once it sees a Failure. A recursive implementation might be the more functional approach, but won't appeal to everyone:

    const validateUntilFailure = ([rule, ...rules], x, result = Success()) => 
      Failure.hasInstance(result) || !rule
        ? result
        : validateUntilFailure(rules, x, result.concat(rule(x)))
    

    Check out the example below for running code. There's a section commented out that shows how to run all rules, even if there are Failures.

    const { Success, Failure } = folktale.validation;
    
    const validateIsObject = (x) =>
      x !== null && x.constructor === Object
      	? Success(x)
    	  : Failure(['Input is not an object']);
    
    const validateHasRightKeys = (x) =>
      ["a", "b"].every(k => k in x) 
      	?  Success(x)
    		:  Failure(['Item does not have a & b.']);
    
    const validateHasRightValues = (x) =>
      x.a < x.b
      	? Success(x)
    		: Failure(['b is larger or equal to a']);
    
    
    // This doesn't work because it calls all validations on
    // every item
    /*
    const validateItem = (x) =>
      Success().concat(validateIsObject(x))
               .concat(validateHasRightKeys(x))
               .concat(validateHasRightValues(x))
               .map(_ => x);
    */
    
    // General validate until failure function:
    const validateUntilFailure = (rules) => (x) => rules.reduce(
      (result, rule) => Success.hasInstance(result) 
        ? result.concat(rule(x)) 
        : result,
      Success()
    );
    
    // Let's try it out!
    const testCases = [
      null,
      { a: 1 },
      { b: 2 },
      { a: 1, b: 2 },
      { a: 2, b: 1 }
    ];
    
    const fullValidation = validateUntilFailure([
    	validateIsObject, 
      validateHasRightKeys,
      validateHasRightValues
    ]);
    
    
    
    console.log(
      testCases
        .map(x => [x, fullValidation(x)])
        .map(stringifyResult)
        .join("\n")
    );
    
    function stringifyResult([input, output]) {
      return `input: ${JSON.stringify(input)}, ${Success.hasInstance(output) ? "success:" : "error:"} ${JSON.stringify(output.value)}`;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/folktale/2.0.1/folktale.min.js"></script>