Advanced form validation with Angular and Joi
As web developers, a lot of our work is about gathering information and data from our users. In an ideal world we could let users input data anyway they like and somehow parse or extract the useful and relevant information from that data, but in reality that is not often the case. Many times, the data users input is complex and require us to make sure that it meets some kind of specification.
Over the last couple of years I’ve been working quite a bit with validating user input in Angular applications. For that purpose, I’ve been using the validation library Joi in its various versions. Joi is a powerful library and since it’s javascript, it can be run both in the front end and back end. That is a great benefit! If you are not running a javascript back end, it is still a great tool for front end validation.
If you are using template driven forms in Angular, you can use the Joi validation to, for instance, run the ngModel through the Joi validation engine after the form was submitted, and then informing the user if the input did not pass validation. But that makes for a poor user experience. And hooking up events to monitor user input, run input through validation, adding and removing classes to highlight errors is tedious work. However, using reactive forms, which has built in support for validation, this becomes a lot easier.
While researching how to use reactive forms together with Joi, I found this article that helped a lot: Advanced validation with Angular and Joi. But it is now a few years old, and it does not address how to validate a whole form with one Joi schema. This is what I aim to show in this article.
What you will learn
- Creating reusable specifications for user input
- Using the spec to ensure a form contains correct data before submission
- Visualizing the errors to the user
- Customizing error messages
- Cross field validation: if one field has X value, another field must be Y
- Validating forms with variable number of inputs (dynamic forms)
What you will not learn
- Designing great forms
- Translating error messages
- Managing browser differences in form rendering
Prerequisites
The setup here is based on angular v9.1.4 and node v12.16.3 (LTS), which are the latest at the time of writing. To make use of this article, you’ll need to be able to use npm, node and ng commands in a terminal of some kind. Also, I’ve only tested the CSS in Chrome, and it may not look the same in another browser.
Note on the images in this article
To visualize the validation I use animated gif images. It has come to my attention that sometimes these images do not load correctly, displaying instead as some kind of unsharp blob. If that happens to you, please try right clicking the image and opening it in a separate tab. I’ve seen this happening on other articles on Medium as well.
Basic setup
Create an Angular app like this:
ng new joi-validation
[some work takes place]
cd joi-validation
Now import the ReactiveFormsModule into your app.module (or other module if you are an advanced user and need to do that)
Next, it’s time to set up a form that we want to validate. Let’s create a “report incident” form, in which a user can report an incident on a construction site or similar. We can create the form directly in app.component.ts for simplicity. To minimize the code to write, we’ll use the FormBuilder service to build the FormGroup and its controls. This is an approach I recommend.
Here is a gist of all three files, obviously you’d not put all code in one file in a real project.
Adding Joi
Right now there is no validation on the form. Let’s change that.
npm install @hapi/joi -s
Now we’ve installed Joi and are prepared to use it to create a schema which we’ll use to validate the input in the form.
In a real project, it is more likely that the schemas we use are located in a separate repository or package and shared within the team or organisation. But in this case, we’ll just create the schema in the app.component.
While we’ve not used the schema to validate the form, we can test the schema like this:
As you start editing the form, you’ll see that the console fills up with error messages:
You can also see that validation returns early: as soon as a value does not pass validation, the validation stops and returns the first error. This is the default behavior in Joi, but can be changed when validating by passing an options object to the validate function:
schema.validate(values, { abortEarly: false })
Connecting Schema to Form
So far, the form is still “valid” as we have not connected the form with our schema. For this to work, we’ll need to create a validator function that can accept our form controls and run them through the validation, and then inform about any errors that occurred.
A factory function
As many other articles on the subject — and the Angular documentation itself — mentions, to validate the FormGroup (the whole form really), you can add one or serveral ValidatorFn:s to the form group. Usually, this technique is used to validate a form with cross control / cross field validation dependencies. For example, cross field validation could be: “if name is ‘Jake’, age can’t be ‘104’” or something like that. But in this use case, we are taking advantage of this feature to use our schema to validate the whole form. The schema may of course have cross field validation dependencies. (More on that later.)
Since the Joi library doesn’t provide us with Angular ValidatorFn:s, we’ll need to create them ourselves. This can be accomplished by writing a function that takes a Joi schema and returns a function that accepts a FormGroup as input (a factory function). To reduce complexity, I’ve chosen to target FormGroup only, not AbstractControl from which it inherits.
To use the factory function, we call it in the creation of our form, like this:
Now, the validator function will be run when Angular detects changes in the form (there are options for this like 'blur'
or 'change'
). The function will find any values not meeting the criteria in the schema and gather the errors produced into an errorObj
, which is just a key value-container. The function will also (and this is important) set an error on any control that contains a value that does not meet the criteria. If the function did not do that, the form would be invalid (in the case that values do not pass), but the corresponding control would still be “valid”, as Angular does not try to match errors on the whole form to a corresponding control. This is by design. Again, we are really using the cross field validation feature to use our schema on the whole form, which was probably not the primary use case for the designers of the FormBuilder.
A note on the setError function
The eagle-eyed may have identified that when a field in the form that previously had an error does not generate any validation error, we are not removing the error on that field. One could then think that if a field had an error at any point, it would always be there. But that is not the case. The reason for this is that we are not attaching any validator functions to each field in the form. This means that on each validation run, each changed field will be marked as “valid” and when the formGroup
validator (our custom validator) is run, it will set errors on any field that fails to pass validation. However, there is a caveat to this approach that we will need to handle later.
Visualising the errors
We have the validator function running on our form, but there is no visual feedback for our users. However, by using the inspector in the browser, we can see that input elements are now marked as invalid or valid as we type:
The class 'ng-invalid'
becomes 'ng-valid'
when the number of charcters in the Title field exceed the threshold. As you can see, the form
element still has the class 'ng-invalid'
after the Title field became valid, as the form contains other errors.
How you want to visualize these errors to your users is entirely up to you. With these CSS classes you are able to highlight which field is invalid and show an error message. Here is an example:
Which could look like this for instance:
Recap
So far we have:
- created a reusable specification that our form must meet
- used the specification to validate the entire form
- highlighted the fields that contain errors
- explained what the problem is (sort of)
What we haven’t done yet:
- used human friendly language to explain what errors occurred
- cross field validation
- dynamic forms validation
Being human
Usually, we want to be as human friendly as possible when communicating with our users. In the visualization above, some of the error messages are OK, but the last one, siteId
requires knowledge of RegExp to be understood. That is not great. Thankfully, Joi can fix this by letting us set the error message directly. This works OK given that the message does not need to explain the context and that it is in english.
Each Joi type has an optional message('My message')
function. By setting that, you are overriding the default message that will be returned if a value does not pass validation:
incidentSchema = Joi.object({
type: Joi.any().valid('Lethal', 'Major', 'Minor'),
title: Joi.string().required().min(5).max(32),
description: Joi.string().required().min(20), // Add a human friendly message to display if
// the Site ID does not pass validation
//
siteId: Joi.string().pattern(/^[a-zA-Z]{2}[0-9]{1,3}$/).message('Site ID must contain two letters and one to three digits. Example "AB123"')
});
At the time of writing this article, Joi does not have a native way to translate error messages. If you need to support non-english languages or a multi-language application, I suggest taking a look at this article. In an Angular project it is probably best to use the built in TranslateService and build something that can interecept the error objects from Joi and transform them into translation key, as shown in the article. However, the article does not support dynamic error messages like “‘title’ must contain at least [dynamic value] characters”. This can definitely be supported by TranslateService, but will require a bit of work. Perhaps that is a subject for another article.
Now that we’ve improved the error message, let’s add some cross field validation.
Cross field validation
For the sake of this article, we need a scenario for cross field validation. Let’s decide that for Site ID “AB123” only major incidents can be reported. So how can we enforce this?
For this to work we will use the Joi function when()
. when()
takes a condition and options, which together form a “if this, then do that, otherwise this other thing” validation (scientific term 🙄). It should be noted that this type of validation has some impact on performance and that it can only be performed on the any()
type. Documentation can be found here.
We’ll modify the schema and do some other related changes, so that the Type field will display an error if the Site ID is “AB123” and the Type is not “Major”. We’ll also make it optional to display errors on fields that are “pristine”, since the Type field may or may not have been changed by the user.
Which results in this:
As you can see, when the Site ID is “AB123”, Type must be “Major”.
Errors get stuck! 💥
As mentioned previously, we haven’t had the need to remove any of the errors that we set on each field in our validator function. This approach works fine as long as we don’t have any cross field validation. It works because when we change the content of a field, Angular removes any errors on that field and then runs validations. But when we set an error on a field we are not editing, the error we be on that field until we remove it or the field changes. This leads to buggy behavior in our form. A gif to illustrate the problem:
To fix this we can use the ValidatorFn to loop through all controls and remove errors from those with error like this:
const validator: ValidatorFn = (group: FormGroup) => {
// Remove error from controls
for (const key in group.controls) {
const control = group.get(key);
if (control.errors) {
control.setErrors(null);
}
}
... rest of validator function
}
Our ValidatorFn is now getting a bit big, and it may be a good idea to split it into smaller functions. We could extract, for instance, the “remove errors” loop into its own function and call it, composing our ValidatorFn out of smaller parts. But to keep this article from becoming way too big, we’ll leave it as is for now.
Dynamic forms
Sometimes we need to let the user add fields to an existing form. In this example, we will allow the reporter to add fields to report names of persons who were affected by the incident. We’ll add a button to add fields, and then validate that the content of the added fields meets a specification. How to actually add the fields to the form is described in the Angular documentation.
Validating the values of a form proved to be quite simple. We’ll use the Joi array()
type to let Joi check validity of a number of values. The ValidatorFn already has what it needs to find fields with errors and mark them. We’ll just need to add a container to show the error message to the user.
We’ve also added some default values to the form, to reduce need for typing in the example.
To make the error message more human friendly we’ve used the messages()
function in Joi to add multiple custom error messages to the Schema. I’m not convinced that is the way to go for managing more complex scenarios, but it is an option if you are not working with a multilingual application.
Final thoughts
I hope this article can help you improve the stability and maintainability of your user input validation. Being able to use the exact same validation in the front and back end of a web application is very useful. If you are not running a javascript back end you can still improve the maintainability of your validation and consequently your forms by creating schemas for user input (or other types of input). The schemas can (should?) be test driven and reused. Extracting the ValidatorFn factory to a shared location can help a team use the same methods to validate and improve the robustness of the validation.
Always mind the user experience
While these tools can help us build advanced forms, it is very important to be mindful of the user experience. Many times we can remove the need for advanced validation and complicated forms by taking a step back and asking ourselves and our users: how can we make this as simple and pain free as possible? It is often easy to add “just one more field”, which in the long run creates a user experience that can be frustrating, even more so for the smartphone and tablet users.
This “just one more field” problem is the reason I chose to not include forms with nested controls in the article. It is definitely possible to build and validate forms with fields that are FormGroups, but is it really a good idea?
If the user needs to input a lot of data, it is often better to split the form into several parts, each part responsible for a well defined set of data. This helps the user to concentrate on one part at a time, at the same time making it easier to guide the user with validation feedback, since there is less risk of a validation error popping up in a location far from the field being edited.
Take help from a UX designer, or none is available to you, take inspiration from the many good examples on the web.
Feedback
While I’ve certainly done my best to write a helpful article, there may be parts that are unclear or — worse — incorrect. Feel free to comment below if you have any questions, corrections or suggestions.
Source code
Here is a link to a stackblitz of the app in its “final” state https://stackblitz.com/edit/angular-ivy-r4rwpw. Link to repo on GitHub: https://github.com/herrklaseen/joi-validation-examples.