Reactive Form Validation in Angular: Built-in, Custom and Async Validators
In Angular reactive forms, validation lives in the component class: each FormControl receives an array of validator functions, the form tracks valid, errors and status for every control, and the template only displays the result. Use the built-in Validators for common rules, write a ValidatorFn for custom rules, attach group-level validators for rules that compare fields, and an AsyncValidatorFn for server checks.
The code targets current Angular (version 21 was used to run it): standalone components, typed forms and the built-in @if control flow. The validation logic shown was executed in Node.js against @angular/forms and type-checked in strict mode.
Set up a typed form
Import ReactiveFormsModule in the standalone component and build the form with FormBuilder. The nonNullable builder makes reset() return controls to their initial values instead of null, and lets TypeScript infer precise types for every value.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-signup',
imports: [ReactiveFormsModule],
templateUrl: './signup.component.html',
})
export class SignupComponent {
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
username: ['', {
validators: [Validators.required, Validators.minLength(3), forbiddenNameValidator(/bob/i)],
asyncValidators: [usernameAvailable],
updateOn: 'blur' as const,
}],
email: ['', [Validators.required, Validators.email]],
age: [0, [Validators.min(18), Validators.max(120)]],
passwords: this.fb.nonNullable.group({
password: ['', [Validators.required, Validators.minLength(12)]],
confirm: ['', Validators.required],
}, { validators: passwordsMatch }),
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched(); // reveal every error at once
return;
}
console.log(this.form.getRawValue());
}
}
Each control is declared as [initialValue, validators], or [initialValue, options] when you also need async validators or updateOn. Setting updateOn: 'blur' on the username means it is validated when the field loses focus, not on every keystroke, which matters for the async check below.
The built-in validators
| Validator | Error key | Error value |
|---|---|---|
Validators.required | required | true |
Validators.requiredTrue | required | true (for checkboxes that must be ticked) |
Validators.minLength(n) / maxLength(n) | minlength / maxlength | { requiredLength, actualLength } |
Validators.min(n) / max(n) | min / max | { min, actual } / { max, actual } |
Validators.email | email | true |
Validators.pattern(re) | pattern | { requiredPattern, actualValue } |
Note the lowercase error keys for minlength and maxlength, a frequent source of messages that never appear. min, max, minLength and maxLength do not flag an empty value; combine them with required when the field is mandatory. Validators.email is a pragmatic syntax check; the server should still validate addresses, as in the Python email-validator guide.
A custom validator
A validator is a function that receives the control and returns null when the value is valid, or an object describing the error. Wrapping it in a factory lets you pass parameters:
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenNameValidator(pattern: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null =>
pattern.test(control.value ?? '') ? { forbiddenName: { value: control.value } } : null;
}
Keep validators pure and synchronous: no HTTP calls and no side effects. They run on every value change of their control.
Cross-field validation on the group
Rules that involve several fields, like "the confirmation must match the password", belong on the parent FormGroup. The validator receives the group, reads its children, and the error appears on the group rather than on a single control:
export const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
const pass = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
return pass && confirm && pass !== confirm ? { passwordMismatch: true } : null;
};
An async validator
An AsyncValidatorFn returns an Observable or a Promise of the same ValidationErrors | null. Angular only runs it when all synchronous validators pass, and the control's status is PENDING while it waits. In a real app, inject a service and call your API; this stand-in simulates the round trip:
import { AsyncValidatorFn } from '@angular/forms';
import { of, delay, map } from 'rxjs';
const taken = ['admin', 'root'];
export const usernameAvailable: AsyncValidatorFn = (control) =>
of(taken.includes(String(control.value).toLowerCase())).pipe(
delay(300),
map((isTaken) => (isTaken ? { usernameTaken: true } : null)),
);
What the form reports
Feeding deliberately bad values into the form above shows how the errors are structured:
form.setValue({ username: 'Bo', email: 'not-an-email', age: 15,
passwords: { password: 'correct-horse-1', confirm: 'correct-horse-2' } });
console.log(form.valid);
console.log(form.controls.username.errors);
console.log(form.controls.email.errors);
console.log(form.controls.age.errors);
console.log(form.controls.passwords.errors);
form.controls.username.setValue('admin');
console.log(form.controls.username.status); // async check running
// ...after the async validator completes:
console.log(form.controls.username.status, form.controls.username.errors);
false
{ minlength: { requiredLength: 3, actualLength: 2 } }
{ email: true }
{ min: { min: 18, actual: 15 } }
{ passwordMismatch: true }
PENDING
INVALID { usernameTaken: true }
Showing errors in the template
Show a message only after the user has interacted with the field (touched or dirty), and connect it to the input for screen readers with aria-describedby and aria-invalid:
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<label for="username">Username</label>
<input id="username" formControlName="username"
[attr.aria-invalid]="form.controls.username.invalid && form.controls.username.touched"
aria-describedby="username-error">
@if (form.controls.username.pending) {
<p>Checking availability…</p>
}
@if (form.controls.username.touched && form.controls.username.errors; as errs) {
<p id="username-error" role="alert">
@if (errs['required']) { Choose a username. }
@if (errs['minlength']) { Use at least {{ errs['minlength'].requiredLength }} characters. }
@if (errs['forbiddenName']) { This name is not allowed. }
@if (errs['usernameTaken']) { This username is already taken. }
</p>
}
<div formGroupName="passwords">
<label for="password">Password</label>
<input id="password" type="password" formControlName="password">
<label for="confirm">Confirm password</label>
<input id="confirm" type="password" formControlName="confirm">
@if (form.controls.passwords.hasError('passwordMismatch') && form.controls.passwords.touched) {
<p role="alert">The passwords do not match.</p>
}
</div>
<button type="submit">Create account</button>
</form>
Leaving the submit button enabled and calling markAllAsTouched() on an invalid submit is friendlier than a disabled button that gives no hint why. The novalidate attribute turns off the browser's own validation bubbles so Angular's messages are the only ones shown. The accessibility points here echo those in the guide to accessible websites for medical practices.
Changing validators at runtime
When one answer makes another field mandatory, use addValidators, removeValidators or setValidators, then call updateValueAndValidity(); validators are not re-run automatically after the change. Disabled controls are excluded from validation and from form.value; use getRawValue() to read them anyway.
A note on signal forms
Recent Angular versions also ship an experimental signal-based forms API. Reactive forms remain the stable, fully supported choice, and everything above applies to them unchanged.
Summary
Declare rules next to each control with Validators, write custom rules as ValidatorFn factories that return null or an error object, put cross-field rules on the group, use AsyncValidatorFn with updateOn: 'blur' for server checks, and render messages from errors once a control is touched, with ARIA attributes linking each message to its input.
Sources
- Angular documentation, Validating form input.
- Angular documentation, Reactive forms.
- W3C, Web Content Accessibility Guidelines 2.2, success criteria 3.3.1 Error Identification and 3.3.3 Error Suggestion.
WebWhat is a CRUD application?
CRUD means Create, Read, Update, Delete. See how it maps to SQL and REST HTTP methods, with a runnable Python example, validation, soft deletes and idempotency.
WebBuilding a medical practice website
Accessibility, tracking pixels and HIPAA, safe forms, structured data and local search for a clinic site.
WebEvent calendar in Laravel
Build an event calendar in Laravel 10/11 with FullCalendar v6: migration, JSON feed for the visible range, create by selection, drag-and-drop and delete.