Angular Form Async Validators
Async validators cover the checks that need a server answer, such as whether the username someone typed is already taken
In reactive forms we covered sync validation and a simple custom validator. This article focuses on async validators.
Prerequisites
We'll build a registerForm with:
-
Username
- Required, 6–32 letters only
- Mock existing users:
trungvo,tieppt,chautran— registration blocked if the username matches
-
Password
- Required, 6–32 characters, letters, digits, at least one special from
!@#$%^&*
- Required, 6–32 characters, letters, digits, at least one special from
-
Confirm password
- Same rules as password
- Must match the password field
Setup
ng g c registerconst routes: Routes = [ { path: "sign-in", component: SignInComponent }, { path: "sign-in-rf", component: SignInRfComponent },+ {+ path: "register",+ component: RegisterComponent+ }, { path: "", redirectTo: "register", pathMatch: "full" }];const PASSWORD_PATTERN = /^(?=.*[!@#$%^&*]+)[a-z0-9!@#$%^&*]{6,32}$/;
this.registerForm = this._fb.group({ username: [ '', Validators.compose([ Validators.required, Validators.minLength(6), Validators.pattern(/^[a-z]{6,32}$/i), ]), ], password: [ '', Validators.compose([ Validators.required, Validators.minLength(6), Validators.pattern(PASSWORD_PATTERN), ]), ], confirmPassword: [ '', Validators.compose([ Validators.required, Validators.minLength(6), Validators.pattern(PASSWORD_PATTERN), ]), ],});Template:
<div class="container"> <form class="register-form" [formGroup]="registerForm" autocomplete="off" (ngSubmit)="submitForm()" > <h2>Register</h2> <div class="row-control"> <mat-form-field appearance="outline"> <mat-label>Username</mat-label> <input matInput placeholder="Username" formControlName="username" /> </mat-form-field> <pre>{{ registerForm.get("username")?.errors | json }}</pre> </div> <div class="row-control"> <mat-form-field appearance="outline"> <mat-label>Password</mat-label> <input type="password" matInput placeholder="Password" formControlName="password" /> </mat-form-field> <pre>{{ registerForm.get("password")?.errors | json }}</pre> </div> <div class="row-control"> <mat-form-field appearance="outline"> <mat-label>Confirm Password</mat-label> <input type="password" matInput placeholder="Confirm Password" formControlName="confirmPassword" /> </mat-form-field> <pre>{{ registerForm.get("confirmPassword")?.errors | json }}</pre> </div> <div class="row-control row-actions"> <button mat-raised-button color="primary" type="submit" [disabled]="registerForm.invalid" > Register </button> </div> <pre>{{ registerForm.value | json }}</pre> </form></div>Custom validators needed
- Async validator — API check for duplicate username
- Sync group validator — confirm password matches password
1. Async validator for username
Async validators return Promise<ValidationErrors | null> or Observable<ValidationErrors | null>. Username uniqueness usually requires a server round-trip.
Mock API (logs Trigger API call on each invocation):
validateUsername(username: string): Observable<boolean> { console.log("Trigger API call"); let existedUsers = ["trungvo", "tieppt", "chautran"]; let isValid = existedUsers.every(x => x !== username); return of(isValid).pipe(delay(1000));}Two ways to write an async validator:
- A function
(control: AbstractControl) => Promise<...> | Observable<...> - Implement the AsyncValidator interface
We'll use the function style (the interface approach needs injecting services into the validator factory).
validateUserNameFromAPI
const validateUserNameFromApi = (api: ApiService) => { return (control: AbstractControl): Observable<ValidationErrors | null> => { return api.validateUsername(control.value).pipe( map((isValid: boolean) => { return isValid ? null : { usernameDuplicated: true }; }) ); };};Wire it as the third array element on the control:
this.registerForm = this._fb.group({ username: [ '', Validators.compose([ Validators.required, Validators.minLength(6), Validators.pattern(/^[a-z]{6,32}$/i), ]), validateUserNameFromApi(this._api), ],});After six valid letters, the async validator runs on every keystroke:

validateUserNameFromAPIDebounce
Search boxes often wait ~300ms between keystrokes before calling the API. Same idea with timer:
const validateUserNameFromApiDebounce = (api: ApiService) => { return (control: AbstractControl): Observable<ValidationErrors | null> => { return timer(300).pipe( switchMap(() => api.validateUsername(control.value).pipe( map((isValid) => { if (isValid) { return null; } return { usernameDuplicated: true, }; }) ) ) ); };};
Note
Angular doesn't wait for async validators to complete before firing ngSubmit. So the form may be invalid if the validators have not resolved.
While the username check is pending (1s delay), the Register button can become enabled. A fast click submits before validation finishes.
submitForm() { console.log("Submit form leh");}
Fix pattern from Stack Overflow: wait until status is not PENDING, then submit only if VALID.
this.formSubmit$ .pipe( tap(() => this.registerForm.markAsDirty()), switchMap(() => this.registerForm.statusChanges.pipe( startWith(this.registerForm.status), filter((status) => status !== 'PENDING'), take(1) ) ), filter((status) => status === 'VALID'), tap(() => { this.submitForm(); }) ) .subscribe();<form class="register-form" [formGroup]="registerForm" autocomplete="off" (ngSubmit)="formSubmit$.next()"></form>
2. Bonus: validate confirm password
Cross-field check on the FormGroup:
const validateMatchedControlsValue = ( firstControlName: string, secondControlName: string) => { return function (formGroup: FormGroup): ValidationErrors | null { const { value: firstControlValue } = formGroup.get( firstControlName ) as AbstractControl; const { value: secondControlValue } = formGroup.get( secondControlName ) as AbstractControl; return firstControlValue === secondControlValue ? null : { valueNotMatch: { firstControlValue, secondControlValue, }, }; };};Apply at group level:
this.registerForm = this._fb.group( { password: [ "", Validators.compose([ Validators.required, Validators.minLength(6), Validators.pattern(PASSWORD_PATTERN) ]) ], confirmPassword: [ "", Validators.compose([ Validators.required, Validators.minLength(6), Validators.pattern(PASSWORD_PATTERN) ]) ] }, { validators: validateMatchedControlsValue("password", "confirmPassword") } );
Summary
- Async validators:
validate(control): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> - Angular does not wait for async validators before
ngSubmit— guard submit yourself when validators can be pending
Code sample
References
- https://trungk18.com/experience/angular-async-validator/
- Experimenting with Angular reactive forms (Vietnamese)
- Experimenting with Angular template-driven forms (Vietnamese)
