Validation
The validation component is a set utility functions that can be used to ensure any value respects a set of constraints. You can access it through the @banquette/validation
package.
Basic usage
The validation component has been thought from the beginning to be as light and free of dependencies as possible, while still being very powerful. As such, there is no validation service, nor any centralized entity of any sort.
You can validate a value as easily as you would call a utility function:
import { Pattern } from "@banquette/validation";
Pattern(/^[A-Z]/).validate('Test').valid // true
Let's decompose the line:
- First we call the function
Pattern
:This function is validator factory. Its role is to create a validator with the correct configuration. Here we simply give it a pattern to match.Pattern(/^[A-Z]/)
- We then call
validate
on the resulting validator, with the value we want to validate as input:validate('Test')
- The validate function returns a
ValidationResult
which contains a valid attribute that will betrue
if no error has been found, orfalse
otherwise.
TIP
The validation can do much more. This first example is meant to show you that, even with all the complexity we will discuss next, a validator can always be used as a simple standalone function.
Validate an object
In the real world you would rarely want to validate a single value like this, so let's consider a basic User
object:
const user = {
firstName: 'John',
lastName: 'Doe',
email: 'john@doe.com'
};
Because we will use many validators, instead of importing them one by one, we will use a special object that contain all of them, called: V
.
import { V } from "@banquette/validation";
const userValidator = V.Container({
firstName: V.NotEmpty(),
lastName: V.NotEmpty(),
email: V.And(V.NotEmpty(), V.Email())
});
The first thing to note is that, no matter how complex your validation rules are, it all comes down to a single validator at the top of the tree.
Once this validator has been created, you can use it to validate as many values as you want:
// Validate the user we created above.
userValidator.validate(user); // Returns a ValidationResult object
// Validate another object.
userValidator.validate({
firstName: 'Another',
lastName: 'User',
email: 'With invalid email'
}); // Returns a ValidationResult object
Let's briefly talk about the validators we've used:
Container
: this is a special kind of validator. It doesn't validate anything itself, but will call other validators instead.NotEmpty
: check that the value is not empty.And
: execute each validator given to it until one of them fails
Do get a full description of each validator please check out the Built-in validators section that covers all of them in detail.
Validation masks
Imagine you have a big validation tree, composed of multiple objects and many rules, but you only want to validate one particular part of this object.
That's where you need a validation mask.
A mask is simply a string
that supports basic glob pattern notation. You can give one or multiple masks to validate()
with the mask
option of any validator.
Basics
For all the examples below, let's use the following validator:
const validator = V.Container({
name: V.NotEmpty(),
email: V.And(V.NotEmpty(), V.Email(), V.Ajax('//email-not-used')),
tags: V.Compose(
V.Max(5),
V.Foreach(
V.Container({
name: V.NotEmpty(),
color: V.And(V.NotEmpty(), V.Pattern(/^#[0-9A-F]{6}$/))
})
)
)
});
Here we add a single mask "/name" that will only match the root "name" attribute.
validator.validate(obj, {mask: '/name'});
// Will match: /name
To target both name AND email, we could set two masks:
validator.validate(obj, {mask: ['/name', '/email']});
// Will match: /name, /email
Or we could use a glob pattern to match both in a single mask:
validator.validate(obj, {mask: '/{name,email}'});
// Will match: /name, /email
We could make use of "*" to validate all properties of any tag:
validator.validate(obj, {mask: '/tags/*/*'});
// Will match: /tags/{N}/name, /tags/{N}/color
TIP
Please note that only the attributes of each tags are validated here. Not the collection itself (/tags) nor the array element (/tags/{N}), because of the double star (/*/*).
You can include all children To validate any child of /tags
, no matter how deep, you can do:
validator.validate(obj, {mask: '/tags/**/*'});
// Will match: /tags/{N}, /tags/{N}/name, /tags/{N}/color
TIP
Note that the Max
validator set on /tags is still not validated here because the /* limits the matching to children.
To validate everything related to tags, including /tags you must end with a globstar:
validator.validate(obj, {mask: '/tags/**'});
// Will match: /tags, /tags/{N}, /tags/{N}/name, /tags/{N}/color
You can match everything by not specifying any mask or by doing:
validator.validate(obj, {mask: '**'});
// Will match all validators.
Will match every validator except the root one (/), because of the /* that require the validator to have a parent:
validator.validate(obj, {mask: '/**/*'});
// Will match all validators except "/".
Modifiers
You can target synchronous or asynchronous validators by adding :sync
or :async
at the end of any mask.
For example, to only validate synchronous validators:
validator.validate(obj, {mask: '/email:sync'});
// Will only process the NotEmpty() and Email() validators on the `/email` attribute.
And to only validate asynchronous validators:
validator.validate(obj, {mask: '/email:async'});
// Will only process the Ajax() validator on the `/email` attribute.
You can combine the modifiers with the glob patterns we've seen above:
validator.validate(obj, {mask: '/**/name:sync'});
// Will validate any validator with a path ending with "/name" that is synchronous.
Validation groups
Another way tou can filter what validators will apply is to use groups
. This works in two phases:
1) Assign a group to the validator:
const validator = V.Container({
subject: V.NotEmpty({groups: 'full'}),
content: V.NotEmpty({groups: 'full'}),
draftName: V.NotEmpty({groups: 'draft'}),
mailbox: V.NotEmpty({groups: ['full', 'draft']}),
tags: V.Max(5)
});
Here will validate:
subject
,content
andmailbox
if the group isfull
,draftName
andmailbox
if the group isdraft
.tags
only if no group is given tovalidate()
.
2) Use the group when validating:
// Only `draftName` and `mailbox` will be validated.
validator.validate(myObj, {group: 'draft'});
You can give an array of groups to validate
too:
// Every validator expect `tags` will be validated.
validator.validate(myObj, {group: ['full', 'draft']});
If no group is present, only validators with no groups will validate:
// Only `tags` will be validated.
validator.validate(myObj);
Validation result
No matter what validator you execute, they all return a ValidationResult
object. This object contains the following properties:
Attribute | Description |
---|---|
path | The absolute path to the value the result is for. |
violations | An array of ViolationInterface , each of them being an error that have been found. |
valid | A boolean holding if the validation succeeded. |
invalid | Inverse of valid. |
error | true if the validation failed to execute (e.g. an exception has been thrown) |
waiting | true if the validation is still running (async validator). |
status | Centralize the 4 flags seen above in a single value (enum). |
promise | A promise that will resolve when the validation is done (if async). |
children | Child valiation contexts. |
parent | Parent validation context. |
The tree
So the ValidationResult
is in fact a tree where each result can have any number of children.
There is a ValidationResult
object created for each validator that is executed (with the exception of Virtual containers).
Each of these objects is identified by a unique path that follows the structure of the validation tree. Here is an example where the path of each validator is in comment:
V.Container({ // path: /
name: V.NotEmpty(), // path: /name
email: V.And( // path: /email
V.NotEmpty(), // path: /email (no change because the parent is a virtual container)
V.Email() // path: /email (same as above)
),
tags: V.And( // path: /tags
V.Max(3), // path: /tags (no change because the parent is a virtual container)
V.Foreach( // path: /tags (same as above)
V.Container({ // path: /tags/{N} (where N is the index in the array)
name: V.NotEmpty(), // path: /tags/{N}/name
color: V.Pattern(/^#[0-9]{3,6}$/) // path: /tags/{N}/color
})
)
)
});
Violations
Each ValidationResult
can contain any number of violations.
A violation is a validation error. They implement the ViolationInterface
which defines 3 attributes:
path
: the path to the value that failed the validation (e.g. /category/name),type
: the type of error (usually the kebab-cased name of the validator that failed),message
: an optional error message describing the error.
Violations are added to the ValidationResult
that belongs to the validator that added the violation, so they can be several levels deep.
For this reason, there are two methods at your disposal to get the violations you need:
- getViolationsArray: Flattened the violations of all results matching the mask(s) into a single array. If no mask is given, all violations found in the tree will be returned.
getViolationsArray(mask?: string|string[]): ViolationInterface[]
- getViolationsMap: Create an object containing the violations of all results matching the mask(s) indexed by violation path. If no mask is given, all violations found in the tree will be returned.
getViolationsMap(mask?: string|string[]): Record<string, ViolationInterface[]>
Both of the methods we've seen above take a mask
parameter that will filter the violations to be returned.
A mask is a simple string supporting basic glob pattern notation. For example, using our example above, we could do:
// Would get violations of the "/name" ValidationResult
result.getViolationsArray('/name')
// Two notations giving the same result.
result.getViolationsArray(['/name', '/email']); // Here we give two simple patterns (no glob)
result.getViolationsArray('/{name,email}'); // Here a glob pattern matching /name OR /email
// Here we only get errors related to tags names, no matter which tag
result.getViolationsArray('/tags/*/name');
// Here all errors related to any tags itself (so Max(3) is excluded here).
result.getViolationsArray('/tags/*/*');
// Match everything related to tags (Max(3) included). "**" matches {0,N} levels.
result.getViolationsArray('/tags/**');
result.getViolationsArray('/tags/**/**/**'); // Same as above.
// Will get NO ERROR because it only matches "/tags/{N}", not their children. And there is no validator on the array items themselves.
result.getViolationsArray('/tags/*');
// Here we get all violations (same as giving no mask at all).
result.getViolationsArray('/**'); // */
result.getViolationsArray('**'); // Same as above.
// Will get all errors except the root level because of the "/*".
result.getViolationsArray('/**/*');
// Will match every error with a path that ends with "/name".
result.getViolationsArray('/**/name');
TIP
We could have used getViolationsMap
the exact same way, the mask works the same.
Sync vs Async
As seen above, calling validate()
on a validator immediately returns a ValidationResult
object.
You may wonder what happens if the validator does some asynchronous task?
In such a case the promise
attribute of the validator will be set by the validator:
import { Ajax } from '@banquette/validation';
Ajax('//example.com/is-email-available') // Build the validator
.validate('test@domain.tld') // Validate a value
.promise.then((result: ValidationResult) => { // Wait for it to process
if (result.valid) { // Do whatever with the result
// ...
}
});
TIP
If you're confused on how the Ajax
validator works, you can check out its documentation here.
Here we will only concentrate on the async behavior.
As you can see, even if the validator is asynchronous we still immediately get a ValidationResult
as result.
The only differences with an synchronous validator are:
- the
promise
attribute is set - the
status
is set toValidationResultStatus.Waiting
- both
valid
andinvalid
flags arefalse
TIP
Primitive validators will either be sync or async, not both. So you can rely on the fact that validating a synchronous validator will never set a promise.
Container validators can be sync or async, depending on their children.
For example:
const userValidator = Container({
name: NotEmpty(),
email: Email()
});
will always be sync, because both NotEmpty
and Email
are primitive synchronous validators.
Whereas:
const userValidator = Container({
name: NotEmpty(),
email: And(Email(), EmailNotUsed() /* Doing an ajax call */)
});
will be async if the Email()
validator pass, because EmailNotUsed
is asynchronous.
TIP
Even in the example above the validation can be synchronous if you set a Validation mask
that excludes the EmailNotUsed()
validator. For example :sync
.
TLDR; a container will become asynchronous as soon as it encounter an async validator. Otherwise, it will remain sync.
Why?
Most of the time, the validation is synchronous. So the support of asynchronous validators must create as little overhead as possible.
Doing it like this has several advantages:
- If you know all your validators are synchronous, you can use the validation as if asynchronous validation was not even supported. Its support have zero impact on what you write,
- There is only 1 way of creating a validator and using it. We don't have to think about asynchronousity when building the validation tree,
- You always immediately get a result object that you can store and make accessible to your view right away, even with async validators.
If you're in a scenario where you don't know if all your validators will be sync or not, you have to go async all the way. And there is the onReady()
method for that:
unknownValidator // A validator you don't know if sync or async
.validate(value) // Validate will always return a ValidationResult
.onReady() // Method part of ValidationResult that always return a promise
.then((result: ValidationResult) => { // Called when the result is ready.
// ...
});
TIP
You could of course just check if the promise
attribute is set, onReady
is syntactic sugar.
Nested validators
When an async validator is executed, it will automatically make all its parents async too.
This means you can call onReady
(or then
on the promise) at any level of the validation result tree, it will wait for all the children to be finished before resolving.
99% of the time you will only care about the top level validator though, so the fact that a validator deep in the tree takes some time to process is totally transparent.
Just call onReady
on the result of validate
and you're golden.
Types of validators
There are 3 types of validators:
Primitive validators
The basic type of validator. They take a value and check something about it. If the condition doesn't validate, it adds a violation to the validation context.
Most validators are primitive, it includes: NotEmpty
, Email
, Phone
, Min
, Max
, Equal
, Ajax
, Callback
etc.
TIP
Primitive validators are the only type of validator that you should create. The other types of validator should already have every possible validator you may need.
Container
A container validator doesn't do any validation itself, it simply calls other validators. The important thing that differentiates it from a virtual container is that it creates a child validation context for each validator it calls.
This means that when you validate this:
const validator = Container({
category: Container({
name: NotEmpty()
})
});
If the NotEmpty()
constraint fails, the violation will have the following path: /category/name, because a validation context has been created for each level:
- the first
Container
is "/" - it creates a sub context called "category"
- the second
Container
creates a sub context called "name".
NOTE
There are only two types of containers: Container
and Foreach
.
Virtual container
Virtual containers are like normal containers with the exception that they don't create sub validation contexts when they call a validator.
This means if you do:
const validator = Container({
category: Container({
name: And( // Virtual container
NotEmpty(),
Or( // Virtual container
Min(10), Equal('-')
)
)
})
});
A violation on name
will always have the path /category/name, even if it occurred several levels deep in a tree of And
or Or
validators.
That's because no sub context is created, so the validation path doesn't change.
NOTE
There are 4 types of virtual containers: And
, Or
, Compose
and If
.
All built-in validators are described in detail in the next chapter.