Advanced Validations
We have already seen some basic validations, for example, in a previous application we have built a form like this:
myForm = this.fb.nonNullable.group({ title: ['', Validators.required],});The general idea here is that if any of the Validators fail against the input
for that control, then the valid property of the form will be false, e.g:
this.myForm.validWe can also check the validity of just that individual control as well:
this.myForm.controls.title.validWe can also use multiple validators for a single field by providing an array of validators:
myForm = this.fb.nonNullable.group({ title: ['', [Validators.required, Validators.minLength(5)]],});By default, Angular comes with a range of validators that suit most cases:
min— value must be greater than or equal to the specified minimummax— value must be less than or equal to the specified maximumrequired— value must be a non-empty valuerequiredTrue— value must be trueemail— string must match an email pattern testminLength— the string or array must have alengthgreater than or equal to the specified minimummaxLength- the string or array must have alengthless than or equal to the specified maximumpattern— the value must match the specified regex patternnullValidator— a validator that does nothing, can be useful if you need to return a validator but don’t want it to do anythingcompose— allows you to bundle multiple validators together into one validatorcomposeAsync— same ascomposebut allows for asynchronous validations
But, sometimes this is not enough and we might require a validator that is custom made for our specific situation. We are going to cover a couple of advanced techniques for validation in this lesson. We will cover how to create our own asynchronous validator, and also our own custom validator that performs a validation across multiple fields within the form.
Custom Validators
Creating our own custom validators is actually reasonably easy — the general
idea is we create a function that accepts a control and we return null if
the validation passes or we return a map of validation errors if it fails.
As an example, we might create a custom validator at
src/app/shared/utils/adult-validator.ts that looks like this:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const adultValidator: ValidatorFn = ( control: AbstractControl): ValidationErrors | null => { return control.value >= 18 ? null : { adultValidator: true };};We have the control passed in and we access its value. We check if that
value is greater than or equal to 18. If it is, we return null which means
the validation has passed. If it is not, we return an object indicating the
validation errors, which is:
{ adultValidator: true}I think this can be kind of confusing, because providing this map of errors kind
of reads as if the adultValidator passed because it is listed as true. But
really, this is saying that there was an error with adultValidator.
We can then import and use this custom validator in our form like any other validator, e.g:
myForm = this.fb.nonNullable.group({ title: ['', Validators.required], age: [null, adultValidator],});This is a simple example, and really we could just use the min validator for
this, but we will look at more advanced implementations in the next two
examples.
An Asynchronous Validator
A great example of an asynchronous validator is validating whether or not
a given username has been taken or not. Most validators work synchronously
— we instantly know if they are valid or not. But for something like this, we
would need to make a request to a server with the value provided to check it,
and that is going to take some time.
The validator we would create for this situation is a little different. We could
create this validator somewhere like
src/app/shared/utils/username-available-validator.ts but it might look more
like this:
import { Injector } from '@angular/core';import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';import { catchError, map, Observable, of } from 'rxjs';import { UserService } from '../data-access/user.service';
export const usernameAvailableValidator: ValidatorFn = ( control: AbstractControl): Observable<ValidationErrors | null> => { const injector = Injector.create([ { provide: UserService, useClass: UserService }, ]); return injector .get(UserService) .checkUsernameAvailable(control.value) .pipe( map((isAvailable) => (isAvailable ? null : { usernameAvailable: true })), catchError(() => of(null)) );};It is the same idea in general, but there are a few key differences. One big
difference is that we are creating our own injector in order to inject our
UserService so that we can access the checkUsernameAvailable method:
const injector = Injector.create([ { provide: UserService, useClass: UserService }, ]); return injector .get(UserService)This is because we want to use the UserService which needs to be provided to
us by dependency injection, but our function is not running within an “injection
context” meaning that the injector that would provide us with injected
dependencies is not available. Instead, we create our own injector that provides
the UserService.
The alternative to this is creating our usernameAvailableValidator as an
@Injectable service as well, which would make normal dependency injection
available to it, but this makes it more awkward to use in our form.
Our new validator will similarly take in a control and it will return
ValidationErrors or null… But! This time we return an Observable that
will emit those errors or null. You can also do this with a Promise instead of
an Observable.
The checkUsernameAvailable method I have set up for testing looks like this:
@Injectable({ providedIn: 'root',})export class UserService { checkUsernameAvailable(username: string) { // Randomly pass/fail const random = Math.random();
return random < 0.5 ? of(true).pipe(delay(1000)) : of(false).pipe(delay(1000)); }}We randomly return an observable that either emits true or false. In a real
scenario, we could make a request to a server here and return true or false
based on whether the username is available or not.
Our validator checks this value, and maps the response either to null or { usernameAvailable: true } based on whether the username is available or not.
Then we use it in our form like this:
myForm = this.fb.nonNullable.group({ username: ['', Validators.required, usernameAvailableValidator], age: [null, adultValidator], });For context, if we had have created the validator as an @Injectable service
rather than a function with that manual Injector creation stuff, we would
instead have to use the validator like this:
myForm = this.fb.nonNullable.group({ username: [ '', Validators.required, this.usernameAvailableValidator.validate.bind( this.usernameAvailableValidator ), ], age: [null, adultValidator], });When creating a form with FormBuilder we can pass in a third option to our
array for any asynchronous validators.
If we want to display the current status of the asynchronous validator in the template (e.g. whether it is currently in the process of checking) we can set up a signal like this:
usernameStatus = toSignal(this.myForm.controls.username.statusChanges);We could then use that in the template to indicate when a check is in progress:
<input formControlName="username" type="text" /><span>{{ usernameStatus() }}</span>The status should render as either PENDING or VALID (you could use a pipe or some other method to change this into something more visually appealing if you like).
Creating a Confirm Password Validator
The last advanced validation situation we are going to consider is a validation that requires the values of two or more controls — this is one that we will actually be implementing in our next application.
The validators that we have created so far rely on the control being passed in:
export const adultValidator: ValidatorFn = ( control: AbstractControl)We can use that control however we need, like inspecting its value, to determine whether or not the control should be valid. We also supply our validator to one specific control:
myForm = this.fb.nonNullable.group({ username: ['', Validators.required, usernameAvailableValidator], age: [null, adultValidator], });The adultValidator is associated with the age control. But, what about this situation:
createForm = this.fb.nonNullable.group( { email: ['', [Validators.email, Validators.required]], password: ['', [Validators.minLength(8), Validators.required]], confirmPassword: ['', [Validators.required]], } );We have a form used for creating a new account, with some standard Validators
like email, required, and minLength. However, let’s say we also want to
verify that the password and confirmPassword fields match. If we create
a custom validator and attach it to the confirmPassword control, how are we
supposed to get the value of the password field to check it?
As well as supplying validators to individual controls, we can also supply them to an entire group:
createForm = this.fb.nonNullable.group( { email: ['', [Validators.email, Validators.required]], password: ['', [Validators.minLength(8), Validators.required]], confirmPassword: ['', [Validators.required]], }, { validators: [passwordMatchesValidator], } );Now the entire group, which is still technically an AbstractControl, will be
passed into the validator as the control. That validator might look like this:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const passwordMatchesValidator: ValidatorFn = ( control: AbstractControl): ValidationErrors | null => { const password = control.get('password')?.value; const confirmPassword = control.get('confirmPassword')?.value;
return password && confirmPassword && password === confirmPassword ? null : { passwordMatch: true };};This is the same as the normal custom validators we create — we just have
different information available to us through the control because we are
passing in the entire group. We use this to grab both the password and
confirmPassword fields.
We check if both the password and confirmPassword values are present, and we
also check that they are equal. If they are we return null indicating success,
or we pass back an error map.
Recap
There are many different types of scenarios you might run into when trying to validate your forms, but these key concepts should be most of what you will need to know.
Remember that when doing client side validation like this, it is only for user experience. These validations are to help the user successfully fill out the form, not enforce data constraints. Any actual data validation/sanitising needs to happen on the server side — if you only rely on these client side validations they can easily be circumvented by malicious users and you could have bad data being sent to your server/being inserted into your database. The same goes for any sorts of rules we are trying to enforce on the client side, you should consider these only to be suggestions to keep good users on track — malicious users will always be able to circumvent any “security” measures we implement on the client side.