Skip to content

Instantly share code, notes, and snippets.

@alloy-d
Last active August 29, 2015 14:13
Show Gist options
  • Save alloy-d/d929c5023b82660fc9e1 to your computer and use it in GitHub Desktop.
Save alloy-d/d929c5023b82660fc9e1 to your computer and use it in GitHub Desktop.
a less-awful validation framework.
// ## Goals
//
// We want a validation framework with the following attributes:
//
// - Data validations can be specified alongside the data model.
// - Validations match the specificity of the data they validate
// (i.e., if there's a problem with `model.items[1]`, the validation
// fails for `model.items[1]`, *not* `model.items`).
// - Validations can be serialized with the data they validate.
// - Validations can be applied piecemeal: validating `model.thingy`
// can be done without doing a full validation of `model`.
// - Validations compose easily: `model` is valid if it passes its
// own validations *and* `model.thingy` passes its validations.
// - Validations don't need to be aware of their enclosing scope:
// `model.thingy` can be validated without knowing about `model`.
// ## Validator functions
// Let's define some quick validator functions.
//
// A validator function takes a value to be validated
// and returns either `VALID` or an error describing
// why the value is invalid.
var VALID = false;
var Validators = {
lowercaseWithDashes: function (value) {
if (value.match(/[^-a-z]/)) {
return new Error("should contain only dashes and lowercase letters");
}
return VALID;
},
lengthLessThan: function (cap) {
return function (value) {
if (value.length >= cap) {
return new Error("should have fewer than " + cap + " things");
}
return VALID;
};
},
present: function (value) {
if (!value) {
return new Error("must be present");
}
return VALID;
}
};
// ## Motivation
// We have a model:
var Identifier = function(identifier) {
return identifier;
};
// Let's say we want an Identifier to be non-blank and
// composed of lowercase words separated by dashes.
// That sounds like an array of validation functions:
var IdentifierSpec = [Validators.present, Validators.lowercaseWithDashes];
// Ideally we then have an `applyValidation` function
// that takes a model and its validation spec and returns
// something useful:
//
// applyValidation(Identifier("this-is-valid"), IdentifierSpec)
// // => VALID
// applyValidation(Identifier("This is invalid."), IdentifierSpec)
// // => [Error("identifier does not match spec.")]
// ---
// Now let's say we have a more complex model.
var Pair = function(first, second) {
return {
first: first,
second: second
};
};
// We want to validate only the second item in a Pair. It would be fantastic
// to represent that as an Object that mirrors the structure of a Pair:
var PairSpec = {
second: [Validators.present]
};
// The validation function, then, would ideally do something like
//
// applyValidation(Pair("this is", "valid"), PairSpec)
// // => {second: VALID}
// applyValidation(Pair("this is bad", null), PairSpec)
// // => {second: [Error("isn't present")]}
//
// This is super handy, because
//
// applyValidation(Pair("bad", null), PairSpec).second
//
// is the same as
//
// applyValidation(Pair("bad", null).second, PairSpec.second)
//
// and, even more handily, assuming we have an `isValid` function
// that returns `true` if the result of `applyValidation` represents
// a totally valid result:
//
// isValid(applyValidation(Pair("bad", null), PairSpec).second)
//
// is the same as
//
// isValid(applyValidation(Pair("bad", null).second, PairSpec.second))
// ---
// This composes really nicely.
var composed = {
name: Identifier("this-is-a-name"),
pair: Pair("part one", "part two")
};
var composedSpec = {
name: IdentifierSpec,
pair: PairSpec
};
// But let's say we need to have an array of Pairs,
// and we need to validate both the whole array *and*
// each Pair within it.
// Maybe, for example, we want to make sure that
// our model has at most 5 pairs.
var model = {
name: Identifier("here-are-some-pairs"),
pairs: [
Pair("I'm", "Adam."),
Pair(null, "this pair has no first."),
Pair("This pair has no second:", null)
]
};
// This gets a little bit uglier.
//
// `modelSpec.pairs` now has some fake keys:
// - `$collection`, which is a spec for the `pairs` field itself, and
// - `$each`, which is a spec for each thing within `pairs`.
var modelSpec = {
name: IdentifierSpec,
pairs: {
$collection: [Validators.lengthLessThan(5)],
$each: PairSpec
}
};
// In order to preserve symmetry between the model
// and the validation's result, we'd need something
// like this:
//
// applyValidation(model, modelSpec).pairs
// // => {
// // $collection: VALID,
// // 0: {second: VALID},
// // 1: {second: VALID},
// // 2: {second: [Error("isn't present")]}
// // }
//
// This isn't *exactly* the same as the input, in that
// the result isn't an Array. But it *can* be accessed
// in the same way.
//
// This *does* kill the symmetry between the spec and its
// data, since
//
// model.pairs[1]
//
// has a value, but
//
// modelSpec.pairs[1]
//
// does not.
//
// However, we can still define `isValid` such that:
//
// model.pairs[1]
//
// is valid if
//
// isValid(applyValidation(model, modelSpec).pairs[1])
// ## Validation results
// Now, let's define `isValid`.
// For convenience, we'll start with a helper
// that returns either `VALID` or `!VALID`
// for a given result.
function check(validated) {
// Each level of the result will be one of the following:
// - an object with validated components.
//
// {
// name: [VALID],
// pairs: {
// $collection: [VALID],
// 0: [Error("something was wrong")],
// // ...
// }
// }
//
// - an array of results from validator functions.
//
// [VALID, Error("something was wrong")]
//
var each = Util.map(validated, function (value) {
// The trivial case: we're looking at a single
// validation result. It passes the check if it's valid.
if (value === VALID) {
return VALID;
// We might also be looking at an array of validation results.
// If any of those is invalid, then this whole component is invalid.
// Otherwise, this component is valid.
} else if (Array.isArray(value)) {
if (Util.any(value, function (val) { return val !== VALID; })) {
return !VALID;
} else {
return VALID;
}
// Finally, we might be looking at another object.
// This value is valid if that object is valid.
} else if (typeof(value) === "object") {
return check(value);
}
// If none of the above apply, then this isn't valid.
return !VALID;
});
// And now, if any of this value's components are invalid,
// this whole value is invalid.
if (Util.any(each, function (val) { return val !== VALID; })) {
return !VALID;
}
// Otherwise, it's valid.
return VALID;
}
// `isValid` is now just a simple wrapper around `check`.
function isValid() {
return check.apply(null, arguments) === VALID;
}
// ## Applying validations
// `applyValidation` is the function we described above
// that takes a model and a spec for that model and returns
// a similarly structured object that represents the
// validity of the model according to the spec.
function applyValidation(model, spec, name) {
var composite;
name = name || "root";
log("validating " + name);
// In the simplest case, there is no spec.
//
// Anything is valid according to no spec.
if (!spec) {
return VALID;
// If the spec is an array, then it's just an array
// of validation functions. We'll return the result
// of applying them each to the model.
} else if (Array.isArray(spec)) {
return spec.map(function (validator) {
return validator(model);
});
// If the spec is a collection validation spec,
// then we need to apply the `$collection` validator to
// the collection as a whole *and* the `$each` validator
// to each item in the collection.
} else if (isCollectionValidationSpec(spec)) {
composite = {};
Util.each(model, function (item, key) {
composite[key] = applyValidation(item, spec.$each, name + "$each");
});
composite.$collection = applyValidation(model, spec.$collection, name + "$collection");
return composite;
// If none of the above apply, then we're looking at
// another complex validator object. We just need to recurse.
} else {
composite = {};
Util.each(model, function (item, key) {
composite[key] = applyValidation(item, spec[key], name + "." + key);
});
return composite;
}
}
// A collection validation spec has a `$collection` spec
// and a spec for each `$item`.
function isCollectionValidationSpec(spec) {
return spec.hasOwnProperty("$each") && spec.hasOwnProperty("$collection");
}
// ---
// ## Helpers
// `log` is a logging helper for easier debugging in node.
var log = function () {
var util;
if (typeof(require) === "function") {
util = require("util");
util.debug.apply(util, arguments);
} else {
console.log.apply(console, arguments);
}
};
// `Util` is a fake Underscore.
var Util = (function () {
var map = function (thingy, func) {
if (thingy.map) {
return thingy.map(func);
} else {
return Object.keys(thingy).map(function (key) {
return func(thingy[key], key);
});
}
};
var each = function (thingy, func) {
if (thingy.forEach) {
thingy.forEach(func);
} else {
Object.keys(thingy).forEach(function (key) {
func(thingy[key], key);
});
}
};
var any = function (collection, test) {
var result = false;
each(collection, function (val) {
result = result || test(val);
});
return result;
};
return {
map: map,
each: each,
any: any
};
}());
@alloy-d
Copy link
Author

alloy-d commented Jan 9, 2015

Problem: this doesn't allow validations on objects. For instance, there's no way for me to validate that a thing has at least one of first or second defined.

It probably makes sense to do something for objects similar to what is is done with $item for arrays.

@alloy-d
Copy link
Author

alloy-d commented Jan 9, 2015

Much of what's already there would allow this: an "array validator" will already work on an object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment