Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.
Over the past year, our team has created a lot of different forms for a form-heavy application.
One of the difficulties we experiences was validating fields that are dependent on other fields of the form.
For example, conditional validation or validation where multiple fields are needed to validate a control.
Now, when we look back at those forms, we notice that most of the forms are build differently.
We're not the only ones that struggle with this. I asked the question of how to implement a conditional validation in Angular Forms on Twitter, and they were multiple answers on how to tackle this problem. All of them were different but there was a consensus, it isn't always straightforward, nor is it easy.
https://twitter.com/tim_deschryver/status/1360597421340979206
To streamline our forms and to make the validation easier I started a proof of concept to validate forms differently.
Goals
The goal is to come up with a solution for more complex forms, but also that it can be used for the simple forms.
As a developer using this new validation API, I don't want to write a lot of code that introduces subtle bugs. Instead, I just want to declare validation rules.
These validation rules must be reusable (and testable). We get extra points if common rules are built-in, preferably similar to the Angular validators.
I also don't want the solution to be destructive because I don't want to rewrite existing forms.
Therefore, the solution has to build a layer on top of the existing Angular Forms API.
The proposal
The Angular Forms API already provides the basic building blocks to make these goals possible.
An AbstractControl
has the method setError
, which is all that's needed to make a form (control) valid or invalid.
Based on the validity of these form controls, the form derives its validity status.
For asynchronous validators, we'll also need markAsPending
to set the control's status to pending
while a validator is validating its value.
The intention of this new validation layer is only to validate the form value and to make use of the existing Forms API to set and clear the errors of the form controls, and thus also for the form itself.
To build the validation layer, we use the createValidator<T>()
wrapper and assign validation rules to the form properties. A validation rule consists of the details of why the validation has failed. The validation rule also contains a message for a user and can include these details in the message.
To active the validator, it must be subscribed to, and the validator returns an Observable containing all of the messages.
To see this in action, please have a look at the following examples.
The differences
Let's highlight the differences and the benefits compared to the default behavior by comparing the two.
Conditional Validation
To disable and enable form validation based on a control's value, we use when
.
For example, in the validator below name
becomes required when strict
is true.
formValidator = createValidator<FormValue>(this.form, {
name: {
validator: required(),
when: (_, form) => form.strict,
},
})
Without the createValidator
wrapper, we need to juggle with validators by adding or removing validators of the control.
If you use the Taiga UI kit, you can use the tuiValidator
directive.
https://twitter.com/Waterplea/status/1361223782367690754
this.form.get('strict')!.valueChanges.subscribe((strict) => {
if (strict) {
this.form.get('string')!.setValidators(Validators.required)
this.form.get('string')!.updateValueAndValidity()
} else {
this.form.get('string')!.setValidators(null)
this.form.get('string')!.updateValueAndValidity()
}
})
Doing this can quickly become bloated and hazardous for large forms when there's cohesion between different controls.
In our case, we overruled a previously validator with a different one. It was after a couple of debugging sessions that we were able to reproduce this hidden bug.
With the when
syntax, it becomes easier to see the coherence between (multiple) controls and (multiple) validators.
Access to the form value
In each validator, we have access to the value of the whole form.
Having access to the form value is useful when you need to compare properties of a form (e.g. with the equal
rule), or when the validation is based on multiple form values.
In the snippet below, passwordConfirmation
needs to be equal to the value of password
.
formValidator = createValidator<User>(this.form, {
password: [required(), minLength(7)],
passwordConfirmation: equal((user) => user.password),
})
To implement this scenario with the Forms API, we have two options.
One, we can group both controls and create a validator for the form group. This has the disadvantage that the validation message isn't bound to a specific control. For us, this was problematic because our generic controls expect the validation messages to be bound to the control, not to a group, to display a validation message to the user.
The second option is to re-create the validators when the value changes.
this.form.get('password')!.valueChanges.subscribe((password) => {
this.form.get('passwordConfirmation')!.setValidators(CustomValidators.equal(password))
this.form.get('passwordConfirmation')!.updateValueAndValidity()
}
})
Just like Conditional Validation this becomes tricky for bigger forms.
Statically Typed
When you pass a type to createValidator<T>
, the rest of the validation is statically typed.
While building the validator, properties of the form model are auto-completed and the value of a form control and the form will be typed in all of the validator rules. The built-in validators are also typed so we can't make the mistake of using the wrong validator.
For example, you won't be able to use a number validation (e.g. greaterThan
) for a property that holds a string value.
Main difference
To make the above use cases possible, the whole form is validated on every change.
This impact is negligible for synchronous validators because these are just methods that are invoked, thus should be fast and has a low impact on the performance.
This is a different story for asynchronous validators, where this behavior might have an impact.
Impact
As a developer, the createValidator
wrapper intends to make it easier to write and read the validation logic of your Angular forms.
Its minimal API (with the common supplied validation rules) should also allow a quick transition towards the createValidator
wrapper.
Refactoring existing forms will have a minimal impact because the status and the value of the form remain the same as before.
Final thoughts
While this is just a simple proof of concept, I can see the benefits of adding an extra layer on top of the Angular Forms API and use the basis as building blocks. Because of this, and because of the common validation rules that behave the same as the built-in Angular validator I expect it to be a small and quick shift if we decide to pull the trigger.
The form validation is more expressive, and this should help with the development while also improving the maintenance cost.
As always, feel free to let me know your thoughts.
Follow me on Twitter at @tim_deschryver | Subscribe to the Newsletter | Originally published on timdeschryver.dev.
Top comments (1)
I normally just make a custom validator that subscribes to the input that is needed for it to be validated. I'm not going to subscribe to all form changes because thats an expensive call to make.
So when I want to show only 1 error in the case of password and password confirmation, I make sure that the second error doesn't trigger if the first one is already shown. So if password is empty, don't show password confirm error. This can be done in the HTML too, no need for the controller to do that. The password field already blocks submission so you only need to do that once.
And then my password + confirmation is just my password input component, so I don't need to repeat that if I use it in multiple locations. I hardly ever make forms with just inputs.
So when I create a form, in the fields I define, I already insert my validators because as far as I know thát is the Angular way