Yet Another Validation Language (for JavaScript)
... but this one is beautiful.
var as = require('yavl');
var schema = {
name : String,
age : Number
};
as(schema).matches({
name : 'Fred',
age : 40
}); // => true
as(schema).cast({
name : 'Fred',
age : '40'
}); // => { name : 'Fred', age : 40 }
as(schema).validate({
name : 'Fred',
age : '40'
}); // => throws TypeError
- let's get crazy
- basically
- types
- literals and logic
- objects
- arrays
- operators
- transformations
- definitions
- classes
- functions
- getting feedback
- alternatives
as({
id : /^[a-f0-9]{32}$/,
type : as('addition', 'removal', 'update'),
shape : as.defined('shape', {
name : as('polygon', 'polyline', 'line', 'rect', 'ellipse', 'circle', 'path'),
attr : as({ undefined : as(String, Number) }).size(as.lte(100)),
text : as(String).size(as.lte(1000)),
children : as([as.defined('shape')]).or(undefined),
bbox : { x : Number, y : Number, width : Number, height : Number, undefined : Error },
undefined : Error
})
}).matches({
id : 'df13fbb92b9d43a7b53339abfb912cb4',
type : 'update',
shape : {
name : 'circle',
attr : { cx : 10, cy : 10, r : 10 },
bbox : { x : 0, y : 0, width : 20, height : 10 }
}
}); // => true
The function returned from require('yavl')
(we'll label it as
from now on) transforms a schema into a checker for that schema. A checker has the three methods we've seen above:
matches(value)
returnstrue
if the value matches the schemacast(value)
does its best to cast the value to something matching the schemavalidate(value)
throws aTypeError
if the value doesn't match the schema
Schemas can be hashes (as above), arrays, or a selection of JavaScript global objects representing basic types. Once a schema is established, it can be refined with chained, nested or branched operators and filters. Sounds complicated? It isn't. Let's dive in.
as(Number).matches(1);
as(String).matches('1');
as(Boolean).matches(true);
as(Date).matches(new Date);
as(Object).matches({});
as(Array).matches([]);
as(Function).matches(function () {});
as(JSON).matches('"1"');
The Error
object is used to force a mis-match. Only undefined
matches Error
.
as(Error).matches(undefined);
The as
function itself matches anything. This is useful for constructs like '... or anything' (see below).
as.matches(1) && as.matches('1') && as.matches({}) && as.matches(undefined)
as('woah').matches('woah');
as(String).and('woah').matches('woah');
as('woah').or('dude').matches('woah');
as('woah', 'dude').matches('woah'); // shorthand for the above
Objects are strict about their declared keys.
as({ a : Number }).matches({}) === false;
To allow a key to be undefined, use logic.
as({ a : as(Number).or(undefined) }).matches({});
as({ a : as(Number, undefined) }).matches({}); // Shorthand or
On the other hand, an object schema is easy about additional keys ('be liberal in what you accept from others').
as({}).matches({ a : 1 });
A key of 'undefined' means 'anything else'.
as({ undefined : Number }).matches({ a : 1 });
as({ undefined : Number }).matches({ b : 1 });
So you can prevent additional keys using Error
.
as({ undefined : Error }).matches({ a : 1 }) === false;
An empty array is a shortcut for (any) Array
.
as([]).matches([]);
as([]).matches([1, 2]);
But arrays are strict about their declared indexes.
as([Number]).matches([]) === false;
To allow an index to be undefined, use logic.
as([as(Number).or(undefined)]).matches([]);
as([as(Number, undefined)]).matches([]); // Shorthand or
On the other hand, an array schema is easy about additional indexes. However, they need to match the last declared index.
as([Number]).matches([1, 2]);
as([Number]).matches([1, '2']) === false;
as([Number, String]).matches([1, '2', '3']);
as([Number, String]).matches([1, '2', 3]) === false;
To get around this, use the as
function to match anything.
as([Number, String, as]).matches([1, '2', 3, new Date]);
You can prevent additional keys entirely using Error
.
as([Number, Error]).matches([1, 2]) === false;
We've met equality already, with literals. These are actually a shorthand:
as('woah').matches('woah');
as.eq('woah').matches('woah'); // shorthand for the above
yavl's operators are inherited from lodash. So, we have
eq
, lt
, lte
, gt
, and gte
.
as.gt(0).lt(10).matches(1);
We also have regexes, which also has a shorthand:
as.regexp(/a/).matches('a');
as(/a/).matches('a');
Objects and arrays can be transformed with size
, first
, last
, nth
, ceil
, floor
, max
, mean
, min
and sum
.
as.size(1).matches([1]);
as.first(1).matches([1, 2]);
Additional arguments in an aggregation function are actually a schema. So:
as.size(1, 2).matches(['a']); // Is shorthand for...
as.size(as.eq(1).or(2)).matches(['a']);
as.size(1, 2).matches(['a', 'b']);
as.size(1, 2).matches(['a', 'b', 'c']) === false;
When using cast
and validate
, the output of the transformation depends on whether you provided
schema arguments. If you did not, the output is the result of the transformation.
as.size().cast(['a']) === 1;
But if you did, the result is an attempt to cast the contents of the input to suit. This only works
for size
, first
, last
, and nth
.
as.size(2).cast(['a']).length === 2;
as.first(1).cast([0, 2]); // => [1, 2]
These behaviours can be useful in complex casts, like extracting typed information from a regex:
as(/([0-9\.]+)(\w{2})/).nth(1).and(Number).cast('12.3px') === 12.3;
as(/([0-9\.]+)(\w{2})/).nth(1, Number).cast('12.3px'); // => ['12.3px', 12.3, 'px']
Sometimes you want to define something for later. define
creates a definition (without applying it),
and defined
applies something previously defined.
as.define('number', Number).defined('number').matches(1); // Not a particularly useful example
This is useful in recursion. Note that using defined
with more than one argument will
both create and apply the definition.
assert.isTrue(as.defined('group', {
members : [as(Number).or(as.defined('group'))]
}).matches({
members : [1, 2, { members : [3, 4] }]
})); // That's better
function MyObject() {}
as(MyObject).matches(new MyObject());
This is an instanceof
check and so works with sub-classes.
Casting to a class passes the value into the constructor:
function MyObject(n) { this.n = n; }
as(MyObject).cast(1).n === 1;
You can check function parameters and return values using as.function()
followed optionally by returns
.
However, since the checking only happens when you actually call the function, we need to use
cast
or validate
. Casting will also cast the parameters if possible:
function addOne(n) { return n + 1; }
as.function(Number).returns(Number).cast(addOne)('1') === 2;
as.function(Number).returns(Number).validate(addOne)('1'); // throws TypeError (on the argument)
as.function(Number).returns(String).validate(addOne)(1); // throws TypeError (on the return value)
An error thrown by validation will have a message which indicates where the failure happened. If you want to get feedback
from a match, provide a second argument of object type as.Status
to the function. The object
will be populated with an array of failure locations.
var status = new as.Status();
as({ a : Number }).matches({ a : '1' }, status);
// => status.failures is ['object.a.number']
OK let's face it, sometimes you just need something that's been around for a while.