Partial Lenses Validation is a JavaScript library for validating and transforming data
Clone or download
Latest commit f72b7df Aug 14, 2018

README.md

Partial Lenses Validation · Gitter GitHub stars npm

This is a library of validation transform combinators. The main idea is to produce validation errors in the same shape as the data structure being validated. This way validation errors can be accessed at the same path as the data and can be mechanically associated with the corresponding elements of the validated data structure.

Note that the ▶ links take you to a live version of this page and that there is a playground for sharing examples.

npm version Bower version Build Status Code Coverage

Contents

Examples

The following sections briefly describe some examples based on actual use cases of this library.

Event table UI

Imagine a UI — or take a look at this live example — for editing a data structure that is an array (table) of objects (records) that have a date field and an event field:

[
  {"date": "2017-09-11", "event": "EFSA-H"},
  {"date": "2017-09-20", "event": "EFSA-T"},
  {"date": "",           "event": "EFSA-T"}
]

We need to validate that each object has a valid date and an event and that dates and events are unique. Furthermore, we wish to give feedback on all elements with errors so as to guide the user.

Here is a sample set of rules

const rules = V.choose(events => V.arrayIx(V.props({
  date: V.and(
    [isNonEmpty,                  'required'],
    [isValidDate,                 'yyyy-mm-dd'],
    [isUniqueBy('date', events),  'duplicate']),
  event: V.and(
    [isNonEmpty,                  'required'],
    [isUniqueBy('event', events), 'duplicate'])
})))

where

const isNonEmpty = R.identity

function isUniqueBy(p, xs) {
  const counts = L.counts([L.elems, p], xs)
  return x => counts.get(x) <= 1
}

const isValidDate = R.test(/^\d{4}-\d{2}-\d{2}$/)

to give such validation feedback. The rules basically just follow the structure of the data.

Validating with those rules we get a data structure with the potential error feedback at the same location as the offending element:

V.errors(rules, [
  {"date": "2017-09-11", "event": "EFSA-H"},
  {"date": "2017-09-20", "event": "EFSA-T"},
  {"date": "",           "event": "EFSA-T"}
])
// [ null,
//   { event: 'duplicate' },
//   { date: 'required', event: 'duplicate' } ]

The result tells us that the first object is valid (i.e. there are no validation errors in it). The event in the second object is a duplicate. The third object is missing a date and the event is a duplicate.

Library contracts

The interface file of this library, partial.lenses.validation.js, uses the library itself to specify the contracts for the exports.

Assuming process.env.NODE_ENV is not "production" and you pass invalid arguments to a function of this library, you will likely get an error message. For example,

V.validate(
  V.casesOf(
    'type',
    [
      R.identical('number'),
      V.props({
        type: R.is(String),
        value: R.isNumber
      })
    ],
    [
      R.identical('boolean'),
      V.props({
        type: R.is(String),
        value: R.is(Boolean)
      })
    ],
  ),
  {
    type: 'boolean',
    value: false
  }
)
// Error: {
//   "errors": [
//     "partial.lenses.validation: `props` given invalid arguments",
//     [
//       {
//         "value": null
//       }
//     ]
//   ]
// }

throws an error, because R.isNumber is not defined. The error is thrown as soon as the call to V.props is made.

Examples of other libraries using Partial Lenses Validation for contract checking:

Reference

The combinators provided by this library are available as named imports. Typically one just imports the library as:

import * as V from 'partial.lenses.validation'

This library is actually built on top of Partial Lenses transforms. It is also typical to use e.g. Ramda, bound as R in examples, to implement predicates.

Elimination

To use a validation rule one runs it using one of the elimination functions.

Synchronous

In case a validation rule is fully synchronous, it is better to use a synchronous elimination function, because synchronous validation is faster than asynchronous validation.

V.accepts(rule, data) ~> boolean v0.3.0

V.accepts(rule, data) runs the given validation rule on the given data and simply returns true in case the data is accepted and false if not.

For example:

V.accepts(V.arrayIx(R.is(String)), ['Yes', 'No'])
// true
V.errors(rule, data) ~> errors | undefined v0.3.0

V.errors(rule, data) runs the given validation rule on the given data. In case the data is accepted by the rule, the result is undefined. Otherwise the result is an object structure in the shape of the data structure containing the validation errors.

For example:

V.errors(
  V.props({
    no: R.is(Number),
    yes: R.is(String)
  }),
  {
    yes: 101,
  }
)
// { no: null, yes: 101 }

Note that in case a validation error would be undefined, a null is reported instead.

V.validate(rule, data) ~{throws}~> data v0.3.0

V.validate(rule, data) runs the given validation rule on the given input data. In case the data is accepted, the validated output data is returned. In case the data is rejected, an Error object is thrown whose message is the stringified validation error and that has an extra errors property that has the (non-stringified) validation errors.

For example:

V.validate(
  V.props({
    missing: R.is(String)
  }),
  {
    unexpected: 'field'
  }
)
// Error: {
//   "missing": null
//   "unexpected": "field"
// }

Asynchronous

In case a validation rule contains asynchronous parts, it is necessary to use one of the asynchronous elimination functions. Functions that are allowed, but not required, to be asynchronous are indicated in the documentation using the async keyword.

The below ghInfoOfAsync function is a simple asynchronous function that tries to use the public GitHub search API to search for information on a GitHub project of specified name:

async function ghInfoOfAsync(name) {
  const q = encodeURIComponent(name)
  const res = await fetch(`https://api.github.com/search/repositories?q=${q}`)
  const body = await res.json()
  return L.get(['items', L.find(R.whereEq({name}))], body)
}
V.acceptsAsync(rule, data) ~> promise(boolean) v0.3.0

V.acceptsAsync(rule, data) runs the given validation rule on the given data like V.accepts except that the validation rule is allowed to contain asynchronous validation predicates and transformations. The result will always be returned as a promise.

V.errorsAsync(rule, data) ~> promise(errors | undefined) v0.3.0

V.errorsAsync(rule, data) runs the given validation rule on the given data like V.errors except that the validation rule is allowed to contain asynchronous validation predicates and transformations. The result will always be returned as a promise.

For example:

V.errorsAsync(
  V.arrayId(
    R.pipeP(ghInfoOfAsync, L.get('stargazers_count'), R.lte(100))
  ),
  [
    'partial.lenses',
    'partial.lenses.validation'
  ]
).catch(R.identity).then(console.log)
// [ 'partial.lenses.validation' ]
V.tryValidateAsyncNow(rule, data) ~{throws}~> data | promise(data) v0.3.0

V.tryValidateAsyncNow(rule, data) runs the given validation rule on the given data like V.validateAsync except that in case the validation result is synchronously available it is returned or thrown immediately as is without wrapping it inside a promise. In case the result is not available synchronously, a promise is returned.

V.tryValidateAsyncNow can be used for wrapping asynchronous functions, for example, because the first stage of validating a function is always synchronous.

For example:

const ghInfoOfAsyncChecked = V.tryValidateAsyncNow(
  V.dependentFn(
    V.args(R.and(R.is(String), V.not(R.isEmpty))),
    name => V.optional(
      V.propsOr(V.accept, {
        name: R.identical(name),
        stargazers_count: R.is(Number)
        // ...
      })
    )
  ),
  ghInfoOfAsync
)
V.validateAsync(rule, data) ~> promise(data) v0.3.0

V.validateAsync(rule, data) runs the given validation rule on the given data like V.validate except that the validation rule is allowed to contain asynchronous validation predicates and transformations. The result, whether accepted or rejected, is returned as a promise.

For example:

V.validateAsync(
  V.arrayId(
    V.and(
      R.is(String),
      V.acceptWith(ghInfoOfAsyncChecked),
      V.keep(
        'name',
        V.propsOr(V.remove, {
          name: R.is(String),
          stargazers_count: V.and(
            R.is(Number),
            [R.lte(1000), n => `Only ${n} stars. You know how to fix it!`]
          )
        })
      )
    )
  ),
  [
    'partial.lenses',
    'partial.lenses.validation'
  ]
).catch(R.identity).then(console.log)
// Error: [
//   {
//     "stargazers_count": "Only 448 stars. You know how to fix it!",
//     "name": "partial.lenses"
//   },
//   {
//     "stargazers_count": "Only 5 stars. You know how to fix it!"
//     "name": "partial.lenses.validation",
//   }
// ]

General

It is also possible to run validation rules with an arbitrary computational monad such as a monad based on observables.

V.run({Monad, onAccept: data => any, onReject: error => any}, rule, data) ~> any v0.3.0

V.run({Monad, onAccept, onReject}, rule, data) runs the given validation rule on the given data using the specified computational monad and either calls the accept callback with the validated data or the reject callback with the validation errors.

The parameters Monad, onAccept, and onReject are optional and default to what V.validate uses. The Monad parameter needs to be a Static Land compatible Monad with all the four functions. If you specify the Monad, you will likely want to specify both onAccept and onReject as well.

Primitive

At the most basic level a rule either accepts or rejects the value in focus.

V.accept ~> rule v0.3.0

V.accept accepts the current focus as is. V.accept should be rarely used as it performs no validation whatsoever.

V.acceptAs(value) ~> rule v0.3.0

V.acceptAs(value) accepts the current focus and replaces it with the given value. V.acceptAs is rarely used alone, because it performs no validation as such, and is usually combined with e.g. V.and.

For example:

V.validate(V.and(R.identical(1), V.acceptAs('one')), 1)
// 'one'

V.acceptWith(async (value, index) => value) ~> rule v0.3.0

V.acceptWith(fn) accepts the current focus and replaces it with the value returned by the given possibly async function. V.acceptWith is rarely used alone, because it performs no validation as such, and is usually combined with e.g. V.and.

In a logical V.or each rule gets the same value as input and the result of the first accepting rule becomes the result. In a logical V.and the output of a previous rule becomes the input of the next rule.

For example:

V.validate(
  V.and(
    V.or(
      V.and(
        R.is(Number),
        V.acceptWith(n => `number ${n}`)
      ),
      R.is(String)
    ),
    V.acceptWith(R.toUpper)
  ),
  10
)
// 'NUMBER 10'

V.reject ~> rule v0.3.0

V.reject rejects the current focus as is. In case the focus is undefined, the error will be null instead.

The idea is that the validation error data structure simply contains the parts of the validated data structure that weren't accepted. This usually allows a programmer who is familiar with the system to quickly diagnose the problem.

For example:

V.errors(
  V.propsOr(V.reject, {}),
  {
    thisField: 'is not allowed',
  }
)
// { thisField: 'is not allowed' }

V.rejectAs(error) ~> rule v0.3.0

V.rejectAs(error) rejects the current focus as the given error value. In case the given error value is undefined, it is replaced with null instead.

Using V.rejectAs one can specify what the error should be. This way an error data structure can be constructed that can, for example, contain error messages to be displayed in a form that an end user can understand.

For example:

V.errors(
  V.propsOr(V.rejectAs('Unexpected field'), {}),
  {
    thisField: 'is not allowed',
  }
)
// { thisField: 'Unexpected field' }

V.rejectWith(async (value, index) => error) ~> rule v0.3.0

V.rejectWith(fn) rejects the current focus with the error value returned by the given possibly async function from the value in focus. In case the return value is undefined, the error will be null instead.

Using V.rejectWith one can specify what the error should be depending on the value in focus. This allows detailed error messages to be constructed.

For example:

V.errors(
  V.propsOr(
    V.rejectWith(value => `Unexpected field: ${JSON.stringify(value)}`),
    {}
  ),
  {
    thisField: 'is not allowed',
  }
)
// { thisField: 'Unexpected field: "is not allowed"' }

V.remove ~> rule v0.3.0

V.remove replaces the not yet rejected value in focus with undefined, which means that it is removed from the surrounding array or object. Beware that V.remove by itself performs no validation. You usually combine V.remove with e.g. V.and or V.propsOr.

For example:

V.validate(
  V.propsOr(V.remove, {
    required: R.is(String)
  }),
  {
    required: 'field',
    unexpected: 'and removed'
  }
)
// { required: 'field' }

Predicates

Unary (and binary) functions are implicitly treated as predicates and lifted to validation rules using V.where.

V.where(async (value, index) => testable) ~> rule v0.3.0

V.where(predicate), or using the shorthand notation predicate, lifts the given possibly async predicate to a validation rule. In case the focus does not satisfy the predicate, it is rejected with V.reject.

Note that explicitly calling V.where is typically unnecessary, because unary (and binary) functions are implicitly treated as predicates and lifted with V.where to rules in this library.

For example:

V.validate(
  V.props({
    isNumber: V.where(R.is(Number)),
    alsoNumber: R.is(Number) // <-- implicit `V.where`
  }),
  {
    isNumber: 101,
    alsoNumber: 42
  }
)
// { isNumber: 101, alsoNumber: 42 }

In case the predicate throws an exception, the focus is rejected with the exception as the error value.

Elaboration

It is also possible to modify the error after a rule has rejected the focus.

V.modifyError(async (value, error, index) => error, rule) ~> rule v0.3.0

V.modifyError(fn, rule), or using the shorthand notation [rule, fn], acts like rule except that in case the rule rejects the focus, the error is computed using the given possibly async function that is given the value in focus, the error from the rule and the index of the focus. In case the given function returns undefined, the error will be null instead.

Note that the shorthand notation [rule, fn] can be used instead of a more verbose function call. This shorthand is provided to make it more convenient to attach detailed error messages to rules.

For example:

V.errors(
  V.choose(data => {
    const expectedSum = L.sum(['numbers', L.elems], data)
    return V.props({
      numbers: V.arrayIx(R.is(Number)),
      sum: [ // <-- Implicit `V.modifyError`
        R.identical(expectedSum),
        actualSum => `Expected ${expectedSum} instead of ${actualSum}`
      ]
    })
  }),
  {
    numbers: [3, 1, 4],
    sum: 9
  }
)
// { sum: 'Expected 8 instead of 9' }

V.setError(error, rule) ~> rule v0.3.0

V.setError(error, rule), or using the shorthand notation [rule, error] when error is not a function, acts like rule except that in case the rule rejects the focus, the given error is used instead. In case the given error is undefined, it is replaced with null instead.

Note that the shorthand notation [rule, error] can be used instead of a more verbose function call when the error is not a function. In case error is a function, it is called like with V.modifyError. This shorthand is provided to make it more convenient to attach detailed error messages to rules.

For example:

V.errors(
  V.choose(data => {
    const expectedSum = L.sum(['numbers', L.elems], data)
    return V.props({
      numbers: V.arrayIx(R.is(Number)),
      sum: [ // <-- Implicit `V.setError`
        R.identical(expectedSum),
        `Expected ${expectedSum}`
      ]
    })
  }),
  {
    numbers: [3, 1, 4],
    sum: 9
  }
)
// { sum: 'Expected 8' }

Logical

Logical connectives provide a simple means to combine rules to form more complex rules.

V.and(...rules) ~> rule v0.3.0

V.and(rule1, ..., ruleN) validates the value in focus with all of the given rules one-by-one starting from the first given rule. In case some rule rejects the focus, that becomes the result of V.and. Otherwise the result of V.and is the accepted result produced by passing the original focus through all of the given rules. Note that V.and is not curried like V.both.

V.both(rule, rule) ~> rule v0.3.3

V.both(rule1, rule2) validates the value in focus with both of the given rules starting with the first of the given rules. V.both(rule1, rule2) is equivalent to V.and(rule1, rule2).

V.either(rule, rule) ~> rule v0.3.3

V.either(rule1, rule2) validates the value in focus with either of the given rules starting with the first of the given rules. V.either(rule1, rule2) is equivalent to V.or(rule1, rule2).

V.not(rule) ~> rule v0.3.0

V.not(rule) validates the value in focus with the given rule. In case the rule accepts the focus, V.not rejects it instead. In case the rule rejects the focus, V.not accepts it instead.

V.or(...rules) ~> rule v0.3.0

V.or(rule1, ..., ruleN) tries to validate the value in focus with one of the given rules starting from the first given rule. In case some rule accepts the focus, that becomes the result of V.or. Otherwise the error produced by the last of the given rules becomes the result of V.or. Note that V.or is not curried like V.either.

Arrays

Rules for validating elements can be lifted to rules for validating arrays of elements.

Uniform

All elements in a uniform array have the same form.

V.arrayId(rule) ~> rule v0.3.0

V.arrayId(rule) validates the elements of an array with the given rule. In case one or more elements are rejected, the error is an array containing only the rejected elements.

The idea is that the elements of the validated array are addressed by some unique identities intrinsic to the elements. Filtering out the accepted elements keeps the error result readable.

V.arrayIx(rule) ~> rule v0.3.0

V.arrayIx(rule) validates the elements of an array with the given rule. In case one or more elements are rejected, the error is an array containing the rejected elements and null values for the accepted elements.

The idea is that elements of the validated array are addressed only by their index and it is necessary to keep the rejected elements at their original indices. The accepted elements are replaced with null to make the output less noisy.

Varying

Elements at different positions in a varying array may have different forms.

V.args(...rules) ~> rule v0.3.1

V.args(rule1, ..., ruleN) validates an array by validating each element of the array with a specific rule. If the array is shorter than the number of rules, the missing elements are treated as being undefined and validated with the corresponding rules. This means that rules for optional elements need to be explicitly specified as such. If the array is longer than the number of rules, the extra elements are simply accepted. This is roughly how JavaScript treats function arguments. See also V.tuple.

V.tuple(...rules) ~> rule v0.3.0

V.tuple(rule1, ..., ruleN) validates a fixed length array by validating each element of the array with a specific rule. See also V.args.

For example:

V.accepts(
  V.tuple(R.is(String), R.is(Number)),
  ['one', 2]
)
// true

Note that elements cannot be removed from a tuple using V.remove.

Functions

It is also possible to validate functions. Of course, validating a function is different from validating data, because it is not possible to validate the actual arguments to a function before the function is called and likewise it is only possible to validate the return value of a function after the function returns. Therefore validating a function means that the function is wrapped with a function that performs validation of arguments and the return value as the function is called.

V.dependentFn(rule, (...args) => rule) ~> rule v0.3.0

V.dependentFn(argumentsRule, argumentsToResultRule) wraps the function at focus with a validating wrapper that validates the arguments to and return value from the function as it is called. The rule for validating the return value is constructed by calling the given function with the validated arguments. In case there is no need for the return value rule to depend on the actual arguments, one can use the simpler V.freeFn combinator instead.

For example:

const sqrt = V.validate(
  V.dependentFn(
    V.args(R.both(R.is(Number), R.lte(0))),
    x => y => Math.abs(y*y - x) < 0.001
  ),
  Math.sqrt
)

sqrt(4)
// 2

Note that the wrapped function produced by V.dependentFn is not curried and has zero arity. If necessary, you can wrap the produced function with e.g. R.curryN or R.nAry to change the arity of the function.

V.freeFn(rule, rule) ~> rule v0.3.0

V.freeFn(argumentsRule, resultRule) wraps the function at focus with a validating wrapper that validates the arguments to and return value from the function as it is called. V.freeFn does not allow the rule for the return value to depend on the arguments. If you wish to validate the return value depending on the arguments you need to use the V.dependentFn combinator.

For example:

const random = V.validate(
  V.freeFn(
    V.tuple(),
    R.both(R.lte(0), R.gt(1))
  ),
  Math.random
)

random('Does not take arguments!')
// Error: [
//   "Does not take arguments!"
// ]

Note that the wrapped function produced by V.freeFn is not curried and has zero arity. If necessary, you can wrap the produced function with e.g. R.curryN or R.nAry to change the arity of the function.

Objects

Rules for validating objects can be formed by composing rules for validating individual properties of objects.

V.keep('prop', rule) ~> rule v0.3.0

V.keep('prop', rule) acts like the given rule except that in case the rule rejects the focus, the specified property is copied from the original object to the error object. This is useful when e.g. validating arrays of objects with an identifying property. Keeping the identifying property allows the rejected object to be identified.

V.optional(rule) ~> rule v0.3.0

V.optional(rule) acts like the given rule except that in case the focus is undefined it is accepted without invoking the given rule. This is particularly designed for specifying that an object property is optional.

For example:

V.validate(
  V.arrayIx(
    V.props({
      field: V.optional([R.is(Number), 'Expected a number'])
    })
  ),
  [
    {notTheField: []},
    {field: 'Not a number'},
    {field: 76}
  ]
)
// Error: [
//   {
//     "notTheField": []
//   },
//   {
//     "field": "Expected a number"
//   },
//   null
// ]

V.props({...prop: rule}) ~> rule v0.3.0

V.props({prop: rule, ...}) is for validating an object and is given a template object of rules with which to validate the corresponding fields. Unexpected fields are rejected. Note that V.props is equivalent to V.propsOr(V.reject).

V.propsOr(rule, {...prop: rule}) ~> rule v0.3.0

V.propsOr(otherwise, {prop: rule, ...}) is for validating an object and is given a rule to apply to fields not otherwise specified and a template object of rules with which to validate the corresponding fields. Note that V.props is equivalent to V.propsOr(V.reject).

Conditional

Rules can be chosen conditionally on the data being validated.

V.cases(...[(value, index) => testable, rule][, [rule]]) ~> rule v0.3.0

V.cases([p1, r1], ..., [pN, rN], [r]) is given [predicate, rule] -pairs as arguments. The predicates are called from first to last with the focus. In case a predicate passes, the corresponding rule is used on the focus and the remaining predicates are skipped and rules ignored. The last argument to V.cases can be a default rule that omits the predicate, [rule], in which case the rule is always applied in case no predicate passes. In case all predicates fail and there is no default rule, the focus is rejected.

For example:

V.validate(
  V.cases(
    [
      R.whereEq({type: 'a'}),
      V.propsOr(V.accept, {
        foo: [R.lt(0), 'Must be positive']
      })
    ],
    [
      V.propsOr(V.accept, {
        foo: [R.gt(0), 'Must be negative']
      })
    ]
  ),
  {
    type: 'b',
    foo: 10
  }
)
// Error: {
//   "foo": "Must be negative"
// }

Note that, like with V.ifElse, V.cases([p1, r1], ..., [rN]) can be expressed in terms of the logical operators, but V.cases has a simpler internal implementation and is likely to be faster.

V.casesOf(traversal, ...[(value, index) => testable, rule][, [rule]]) ~> rule v0.3.4

V.casesOf(traversal, [p1, r1], ..., [pN, rN], [r]) is like V.cases except that subfocuses for the predicates are produced by the given traversal from the current focus and a case is taken if the predicate accepts any one of the subfocuses.

For example:

V.validate(
  V.casesOf(
    'type',
    [R.identical('number'), V.props({type: R.is(String), value: R.is(Number)})],
    [R.identical('string'), V.props({type: R.is(String), value: R.is(String)})]
  ),
  {
    type: 'string',
    value: 'foo'
  }
)
// { type: 'string', value: 'foo' }

V.ifElse((value, index) => testable, rule, rule) ~> rule v0.3.0

V.ifElse(predicate, consequent, alternative) acts like the given consequent rule in case the predicate is satisfied by the focus and otherwise like the given alternative rule.

For example:

V.validate(
  V.ifElse(R.is(Number), R.lte(0), R.is(String)),
  -1
)
// Error: -1

Note that V.ifElse(p, c, a) can be expressed as V.or(V.and(p, c), V.and(V.not(p), a)), but V.ifElse has a simpler internal implementation and is likely to be faster.

Dependent

Rules can depend on the data being validated.

V.choose((value, index) => rule) ~> rule v0.3.0

V.choose(fn) is given a function that gets the current focus and then must return rules to be used on the focus. This allows rules to depend on the data and allows rules that examine multiple parts of the data.

For example:

V.validate(
  V.choose(({a, b}) => V.props({
    a: [R.equals(b), "Must equal 'b'"],
    b: [R.equals(a), "Must equal 'a'"]
  })),
  {
    a: 1,
    b: 2
  }
)
// Error: {
//   "a": "Must equal 'b'",
//   "b": "Must equal 'a'"
// }

Note that V.choose can be used to implement conditionals like V.cases and V.ifElse. Also note that code inside V.choose, including code that constructs rules, is always run when the V.choose rule itself is used. For performance reasons it can be advantageous to move invariant expressions outside of the body of the function given to V.choose. Also, when simpler conditional combinators like V.cases or V.ifElse are sufficient, they can be preferable for performance reasons, because they are given previously constructed rules.

Recursive

Rules for recursive data structures can be constructed with the help of V.choose and V.lazy, which both allow one to refer back to the rule itself or to delay the invocation of a rule computing function.

V.lazy(rule => rule) ~> rule v0.3.0

V.lazy(fn) constructs a rule lazily. The given function is passed a forwarding proxy to its own return value. This allows the rule to use itself as a subrule and construct a recursive rule.

For example:

V.accepts(
  V.lazy(tree => V.arrayId(
    V.props({
      name: R.is(String),
      children: tree
    })
  )),
  [
    {
      name: 'root',
      children: [
        {name: '1st child', children: []},
        {
          name: '2nd child',
          children: [{name: 'You got the point', children: []}]
        },
      ]
    }
  ]
)
// true

Transformation

Rules can modify the value after a rule has accepted the focus.

Ad-hoc

Rules can include simple ad-hoc post-validation transformations.

V.modifyAfter(rule, async (value, index) => value) ~> rule v0.3.3

V.modifyAfter(rule, fn) replaces the focus after the given rule has accepted it with the value returned by the given possibly async function. V.modifyAfter(rule, fn) is equivalent to V.both(rule, V.acceptWith(fn)).

V.setAfter(rule, value) ~> rule v0.3.3

V.setAfter(rule, value) replaces the focus after the given rule has accepted it with the given value. V.setAfter(rule, value) is equivalent to V.both(rule, V.acceptAs(value)).

V.removeAfter(rule) ~> rule v0.3.3

V.removeAfter(rule) removes the focus after the given rule has accepted it. V.removeAfter(rule) is equivalent to V.both(rule, V.remove).

Promotion

Rules can validate versioned data and transform it to another version in the process. The combinators V.promote, V.upgrades, V.upgradesOf are designed for cases where there are multiple versions of data or schema. Using them one can validate any one of the versions and also convert the data to desired version — usually to the latest version — so that rest of the program does not need to deal with different versions.

V.promote(...[rule[, (value, index) => value]]) ~> rule v0.3.6

V.promote is like V.or, but the rules given to V.promote need to be wrapped inside an array [rule] and may optionally include a transformation function, [rule, fn]. V.promote tries, like V.or, to find a rule that accepts the focus. If no such rule is found, the focus is rejected. Otherwise if the accepting rule has an associated function, then the function is used to transform the focus and the same validation process is rerun. This way any sequence of transformations is also validated.

For example:

V.validate(
  V.promote(
    [
      V.props({
        type: R.identical('v2'),
        value: R.is(Number)
      })
    ],
    [
      V.props({
        type: R.identical('v1'),
        constant: R.is(Number)
      }),
      ({constant}) => ({type: 'v2', value: constant})
    ]
  ),
  {type: 'v1', constant: 42}
)
// { type: 'v2', value: 42 }

Note that V.or(r1, ..., rN) is equivalent to V.promote([r1], ..., [rN]).

V.upgrades(...[(value, index) => testable, rule[, async (value, index) => value]]) ~> rule v0.3.6

V.upgrades is like V.cases, but each case may optionally include a transformation function, [predicate, rule, fn]. V.upgrades tries, like V.cases, to find the first passing predicate. When no such predicate is found, the focus is rejected. Otherwise the focus is validated with the associated rule. If the case also includes a possibly async transformation function, the function is used to transform the value in focus and the same validation process is rerun. This way any sequence of transformations is also validated.

For example:

V.validate(
  V.upgrades(
    [
      L.get(['type', R.identical('v1')]),
      V.props({
        type: R.is(String),
        constant: R.is(Number)
      }),
      ({constant}) => ({type: 'v2', value: constant})
    ],
    [
      L.get(['type', R.identical('v2')]),
      V.props({
        type: R.is(String),
        value: R.is(Number)
      })
    ]
  ),
  {type: 'v1', constant: 42}
)
// { type: 'v2', value: 42 }

Note that V.cases([p1, r1], ..., [[pN, ]rN]) is equivalent to V.upgrades([p1, r1], ..., [[pN, ]rN]).

V.upgradesOf(traversal, ...[(value, index) => testable, rule[, async (value, index) => value]]) ~> rule v0.3.6

V.upgradesOf is like V.casesOf, but each case may optionally include a transformation function, [predicate, rule, fn]. V.upgradesOf tries, like V.casesOf, to find the first predicate that accepts any one of the the traversed subfocuses. When no such predicate is found, the focus is rejected. Otherwise the focus is validated with the associated rule. If the case also includes a possibly async transformation function, the function is used to transform the value in focus and the same validation process is rerun. This way any sequence of transformations is also validated.

For example:

V.validate(
  V.upgradesOf(
    'type',
    [
      R.identical('v1'),
      V.props({
        type: R.is(String),
        constant: R.is(Number)
      }),
      ({constant}) => ({type: 'v2', value: constant})
    ],
    [
      R.identical('v2'),
      V.props({
        type: R.is(String),
        value: R.is(Number)
      })
    ]
  ),
  {type: 'v1', constant: 42}
)
// { type: 'v2', value: 42 }

Note that V.casesOf(t, [p1, r1], ..., [[pN, ]rN]) is equivalent to V.upgradesOf(t, [p1, r1], ..., [[pN, ]rN]).

Tips

The following subsections give some tips on effective use of this library.

Prefer case analysis to logical OR

The logical combinators V.or, V.either, and also V.promote, can be convenient, but it is often preferable to use conditional combinators like V.cases and V.upgrades, because, once a case predicate has been satisfied, no other cases are attempted in case the corresponding rule fails and the resulting error is likely to be of higher quality.

Prefer rule templates to logical AND

It might be tempting to use V.and to combine V.propsOr(V.accept) rules

V.and(
  V.propsOr(V.accept, rules1),
  V.propsOr(V.accept, rules2),
  // ...
)

but this has a couple of disadvantages:

  • The resulting rule will accept additional properties.
  • If one of the V.propsOr rules rejects, then errors from later rules are not reported.

It is usually better to combine rule templates inside V.props instead:

V.props({
  ...rules1,
  ...rules2,
  // ...
})

This way additional properties are not accepted and errors from all rules are reported in case of rejection.

Known caveats

Probably the main weakness in the design of this library is that this library specifically tries to avoid having to implement everything. In particular, one of the ideas is to simply allow arbitrary predicates from a library like Ramda to be used as rules. This means that rules do not contain extra information such as a corresponding random value generator of values matching the rule or a traversable specification of the rule for exporting the specification for external tools. One way to provide such features is to pair validation rules with the necessary extra information. It should be possible to do that outside of this library.

The current implementation does not operate incrementally. Every time e.g. V.validate is called, everything is recomputed. This can become a performance issue particularly in an interactive setting where small incremental changes to a data structure are being validated in response to user actions. It should be possible to implement caching so that on repeated calls only changes would be recomputed. This is left for future work.

Related work

This library primarily exists as a result of Stefan Rimaila's work on validation using lenses.