Creating custom validators in Angular isn’t anything new for me, I must have created dozens of them over the years, but strangely enough I haven’t had to create “async” validators in recent memory. When I tried to find more information about making a custom validator that returned an observable (or a promise), there really wasn’t a heck of a lot of information out there. Or more so, it was spread out in piecemeal code slices.
This post should hopefully group some of the core concepts together, specifically, at the end I will show how to create a “debounce” validator that only runs when a user stops typing, as this for me was the hardest thing to find examples of!
Basic Async Validator In Angular
For the purposes of this article, I’m going to write an async validator that calls an API to check if a username is currently in use or not. Imagine I’m using this on a sign up form to make sure that no two users pick the same username.
The full code is actually quite simple and looks like so :
import { Directive, forwardRef} from '@angular/core';
import { AbstractControl, AsyncValidator, NG_ASYNC_VALIDATORS, ValidationErrors } from '@angular/forms/';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AccountService } from '../services/account.service';
@Directive({
selector: '[usernameCheck]',
providers: [
{
provide: NG_ASYNC_VALIDATORS,
useExisting: forwardRef(() => UsernameCheckDirective),
multi: true
}
]
})
export class UsernameCheckDirective implements AsyncValidator {
constructor(private accountService : AccountService) {
}
validate(control: AbstractControl): Observable<ValidationErrors | null> {
return this.accountService.exists(control.value).pipe(map(x => x.exists ? { exists : true} : null))
}
}
There are a couple of things to point out. First notice that my provider name is NG_ASYNC_VALIDATORS and *not* NG_VALIDATORS. This is so important because Angular won’t complain if you use NG_VALIDATOR here, instead it will just never show an error (Very very annoying and took me a very long time to debug).
Next notice that my validator method returns an observable. For this, I call my AccountService which has a method called “Exists”. This returns an object with a boolean property called “exists”. If true, it means the username is taken and I should return an object like so
{ exists : true}
Otherwise, I should return null.
Easy right?
When I write HTML to use this, I can just do the following :
<input name="username"
#username="ngModel"
type="text"
class="form-control"
[(ngModel)]="createAccount.name"
[usernameCheck]
required />
<div class="validation-message" *ngIf="username?.errors?.exists">
Username "{{createAccount.name}}" is already in use
</div>
Nothing too special and it works perfectly fine!
Ignoring Empty Values
The first issue I ran into is that as soon as my form loads, I could see it pinging the server to check if a username existed, before I’ve even typed a single character! What I needed was a quick short circuit to return an empty value if the control was empty. Something like this did the trick perfect!
validate(control: AbstractControl): Observable<ValidationErrors | null> {
if(!control.value) {
return of(null);
}
return this.accountService.exists(control.value).pipe(map(x => x.exists ? { exists : true} : null))
}
Now, when the value of my control is empty, the validator simply returns null (e.g. There are no errors), and we are good to go.
But then there was another issue….
Debouncing The Validation
What I noticed was that as I typed, I would validate every single keystroke by calling the backend API. This was way overkill and was resulting in 10’s of API calls when all someone was trying to do was fill in their username. What I needed was instead of validating every single key press, I needed to wait until the user stopped typing, then go ahead and validate the username. This in Angular terms is usually called “debouncing”.
Now I had worked with debounced textboxes before, infact, here’s an article I wrote on that very subject : http://angulartut.onpressidium.com/2021/03/17/creating-a-textbox-with-delayed-output-in-angular/! But the issue was that how could I debounce from within a validator?
Interestingly, I headed to the Angular documentation for help and all it told me was to use the ngModelOptions to only validate on blur like so :
<input [(ngModel)]="name" [ngModelOptions]="{updateOn: 'blur'}">
I personally loathe this method because I hate the fact a user has to click somewhere else on the screen to have validation run. Often forcing them to find some white space to click on the page.
Then I read about an interesting “quirk” within Angular’s Async Validator code. It stated that if an existing validation was in progress when another request for validation came in, it would cancel that initial validation and instead subscribe to that incoming validation request. I suppose it in all likelihood was so that you didn’t end up with a race condition where as you were typing, validators were resulting at odd times and giving mixed results. But could we use this to our advantage? As it so happens, yes we could!
Here’s our new async validate method :
validate(control: AbstractControl): Observable<ValidationErrors | null> {
if(!control.value) {
return of(null);
}
return of(control.value)
.pipe(
delay(250),
//Then instead return the observable of us actually checking the value.
switchMap((value) =>
this.accountService.exists(value).pipe(map(x => {
return x.exists ? {exists : true} : null;
}))
)
);
}
Here’s how it works. When the validator is kicked off, the first thing it does is it waits 250ms. That may seem dumb but there’s a reason. Imagine the user keeps typing, and validation kicks off again? What happens to that first validation request? It gets cancelled! Is that a bad thing? Well no because it was just waiting 250ms so no harm done, kill the process all you want! This continues until the user stops typing, and the full 250ms passes, at which point the backend service will be called!
I’ve seen variations of this that instead utilize debounce, timers, and all sorts of manually created observables. This for me, works without fail. And for me, it makes the most sense. All we are trying to do is tap into that native ability of Angular cancelling in flight validation requests on new validation requests. So we aren’t trying to work around Angular’s limitations, but instead work with it!
💬 Leave a comment