In my
previous post, I described the main points when developing another
opensource library . I forgot to mention one more thing: if you don’t tell anyone about the library, whatever it may be, most likely no one will know about it.
So, meet
trava.js - juicy validation for the benefit of the project. By the way, we have been using grass for more than six months, and I thought it was time to tell you about the benefits of using it. Already even dried, so hold your breath. And go ahead.
Concept
At first glance, it seems that validation is a trivial topic that does not require special attention. The value is either true or not, which could be simpler:
function validate (value) {
But usually it would be nice to know what exactly went wrong:
function validate (value) { if (!check1(value)) return 'ERROR_1'; if (!check2(value)) return 'ERROR_2'; }
Actually that's all, the problem is solved.
If not for one “but”.
From the experience of developing real applications, it was noticed that the matter does not end with validation. Typically, this data also needs to be converted to a specific format, for some reason not supported by the serializer out of the box, for example, dates, sets, or other custom data types. Given that this is mainly JSON, in practice it turns out that you have to do a double pass through the input data structure during validation and transformation. The idea came up, why not combine these two stages into one. A possible plus would also be the presence of an explicit declarative data schema.
To support the conversion of a value to a specific format, the validator must be able to return not only an error, but also a reduced value. In the js world, several interface options are fairly common with possible error returns.
- Probably the most common is the return of the [error, data] tuple:
function validate (value) { if (!check1(value)) return ['ERROR_1']; if (!check2(value)) return ['ERROR_2']; return [null, value]; }
There is also a similar option where not an array is returned, but the {error, data} object, but there are no fundamental differences. The advantage of this approach is the obviousness, the minus is that now you need to support this contract everywhere. For validation, this does not cause inconvenience, but for transformations this is clearly superfluous.
- Use exceptions. Although in my opinion a validation error is a standard situation in the application, nothing is exceptional. Honestly, I think that exceptions are best used only where something really went wrong. Also, exceptions can be accidentally called in the validators themselves, and then you may not know at all that it was an error in the code, and not in the value. The advantage of the approach is the simplification of the interface - now always the value is returned in the usual way, and the error is thrown as an exception.
- There is an option to put an error in a global variable. But I would not pull the state unnecessarily.
- Use a separate type for errors. It looks like the option with exceptions, if you take the type of error from them, but do not throw it away.
function validate (value) { if (!check1(value)) return new Trava.ValidationError({ code: 401 }); if (!check2(value)) return new Trava.ValidationError({ code: 405 }); return parseOrTransform(value);
I settled on the latter option, although this is also a compromise, but overall not bad.
Trava.ValidationError is proposed as a type for the error, which inherits from the standard
Error and adds the ability to use an arbitrary data type to report an error. It is not necessary to use
Trava.ValidationError , you can use the standard
Error , but do not forget that then the error message is only strings.
To summarize, we can say that the validator is a pure, synchronous function that, in addition to the value, can return an error. Extremely simple. And this theory works well without libraries. In practice, validators are combined into chains and hierarchies, and here the grass will come in handy for sure.
Composition
Perhaps the composition is the most common case of working with validators. The implementation of the composition may be different. For example, in the well-known
joi and
v8n libraries, this is done through an object and a chain of methods:
Joi.string().alphanum().min(0).max(255)
Although it looks beautiful at first glance, this approach has several drawbacks, and one is fatal. And here is the thing. In my experience, the validator is always a thing for a specific application, so the main focus in the library should be on the convenience of expanding validators and integration with the existing approach, and not on the number of basic primitives, which, in my opinion, only add weight to the library, but most will not be used. Take for example the same validator for the string. Then it turns out that you need to trim the spaces from the ends, then suddenly you need to allow the use of special characters in one single case, and somewhere you need to lead to lowercase, etc. In fact, there can be infinitely many such primitives, and I just don’t see the point of even starting to add them to the library. In my opinion, the use of objects is also redundant and leads to an increase in complexity during expansion, although at first glance it seems to make life easier. For example, c
joi is not so easy
to write your validator .
A functional approach and grass here may help. The same example of validating a number specified in the range from 0 to 255:
The
Check statement makes a validator out of the truth check (value => true / false). And
Compose chains the validators. When executed, the chain is interrupted after the first error. The important thing is that ordinary functions are used everywhere, which are very simple to expand and use. It is this ease of expansion, in my opinion, that is a key feature of a valid validation library.
Traditionally, a separate place in the validation is occupied by checking for
null and
undefined . There are auxiliary operators in the grass for this:
There are several more helper operators in the grass, and they all compose beautifully and surprisingly simply expand. Like ordinary functions :)
Hierarchy
Simple data types are organized in a hierarchy. The most common cases are objects and arrays. There are operators in the grass that make it easier to work with them:
When validating objects, it was decided to emphasize the severity of the definition: all keys are required by default (wrapped in
Required ). Keys not specified in the validator are discarded.
Some
jsonschema ,
quartet solutions prefer to describe validators as data, for example {x: 'number', y: 'number'}, but this leads to the same difficulties with expansion. A significant advantage of this approach is the possibility of serialization and exchange of circuits, which is impossible with functions. However, this can be easily implemented on top of the functional interface. No need to hide functions behind the lines! Functions already have names and that’s all that is needed.
For ease of use inside validators, the
Compose and
Keys operators can be omitted; it is also convenient to wrap the root validator in
Trava :
const pointValidator = Trava({
If you call
Trava with the second argument, then the return value will be the result of applying the validator:
const point = Trava({ x: [numberValidator, Trava.Check(v => v > 180)], y: [numberValidator, Trava.Check(v => v < 180)], },
So far, support has been implemented only for arrays and objects, as basically poison JSON and that’s enough. Pull Requests for Wellcome!
Context
When using the validator as the last parameter, you can pass the context, which will be accessible from all called validators as the last parameter. Personally, this opportunity has not come in handy for me yet, but it is possible.
For some validators that may return an error, it is possible to define an error message at different levels. Example:
const pos = Trava.Check(v => v > 0); pos(-1);
Override for a single case:
const pos = Trava.Check(v => v > 0, " "); pos(-1);
Override for all cases:
Trava.Check.ErrorMessage = " "; pos(-1);
Also, for a more detailed configuration, you can transfer a function at the place of the error, which should return an error and will be called with the validator parameters.
Use case
Mostly we poison JSON on the backend along with koa. The frontend also sits down slowly. It is convenient to have common validators at both ends. And now I will show an almost real use case. Suppose you want to implement an API to create and update patient data.
common / errors.js const trava = require ('trava');
function ValidationError (ctx, params) {
if (params instanceof Error) {
params = trava.ValidationError.extractData (params);
}
ctx.body = {
code: 'VALIDATION_ERROR',
params,
};
ctx.status = HttpStatus.BAD_REQUEST;
}
Although the example is very simple, it cannot be called simplified. In a real application, only validators will be complicated. You can also make validation in middleware - the validator is applied entirely to the context or to the body of the request.
In the process of working and using validation, we have come to the conclusion that simple synchronous validators and simple error messages are quite enough. In fact, we came to the conclusion that we use only two messages: “REQUIRED” and “INVALID”, which are localized on the frontend along with prompts for the fields. Other checks that require additional actions (for example, at registration to check that such mail already exists) are outside the scope of validation. In any case, the grass is not about this case.
In custody
In this short article, I described almost the entire functionality of the library, outside the scope of the article there are several helpers simplifying the life. I ask for details on github
github.com/uNmAnNeR/travajs .
We needed a tool that can be customized as much as possible, in which there is nothing superfluous, but at the same time there is everything necessary for everyday work. And I think in general this was achieved, I hope someone will also make life easier. I will be glad to wishes and suggestions.
To health.