Angular Reactive Forms Validation: Validators, Async Checks, and Accessible Errors
Angular Reactive Forms model a form as an immutable tree of FormControl, FormGroup, and FormArray instances whose validity is computed by pure validator functions — the same pass/fail/message contract the browser exposes through native constraint checking, lifted into a reactive, testable object graph. This guide covers FormBuilder, the built-in Validators, custom synchronous and asynchronous validators, group-level cross-field rules, ControlValueAccessor for custom controls, and accessible error templates, mapping each back to the native Constraint Validation API Deep Dive and the UX Patterns & Error State Design approaches.
Why Reactive Forms Mirror Native Constraint Validation
A native input exposes validity.valueMissing, validity.patternMismatch, and a validationMessage. Angular’s reactive model exposes the same information as control.errors — an object keyed by error name ({ required: true }, { pattern: { ... } }) — plus control.valid, control.touched, and control.dirty. The parallel is exact: a validator function is the framework analog of a constraint attribute, and control.errors is the framework analog of ValidityState.
The house pattern on this site is <form novalidate> plus a manual checkValidity()/reportValidity() gate. Angular’s [formGroup] directive applies novalidate automatically, and form.valid is the framework-native version of checkValidity(): a synchronous boolean you check before dispatching the payload. You stay in control of when and how errors render, exactly as the native pattern intends.
Reactive Forms Versus Template-Driven Forms
Angular ships two form paradigms. Template-driven forms (ngModel + directives) push validation into the template and are closest to plain native validation — fine for trivial forms but awkward once rules become dynamic. Reactive forms invert the control: the form model is defined in TypeScript as an explicit, synchronously available object tree, and the template merely binds to it. This is the right choice for anything with custom validators, async checks, cross-field rules, or dynamic controls, because the validity of every node is computable and testable without touching the DOM.
| Concern | Template-driven | Reactive |
|---|---|---|
| Source of truth | Template directives | TypeScript model |
| Custom sync validators | Awkward | ValidatorFn, first-class |
| Async validators | Limited | AsyncValidatorFn, first-class |
| Cross-field rules | Hard | Group-level validator |
| Unit testability | Needs a fixture | Pure function tests |
| Dynamic controls | Difficult | FormArray |
Everything below uses reactive forms, the paradigm built for production-grade validation.
Prerequisites
| Requirement | Minimum | Notes |
|---|---|---|
| Angular | 16+ | Standalone components, typed forms |
ReactiveFormsModule |
— | Imported in the component or module |
@angular/forms |
16+ | FormBuilder, Validators, AsyncValidatorFn |
| RxJS | 7+ | Observables for async validators and debounce |
| TypeScript | 5.0+ | NonNullableFormBuilder for typed controls |
Core API Reference
| Symbol | Layer | Purpose |
|---|---|---|
FormBuilder / NonNullableFormBuilder |
Construction | Concise typed creation of groups and controls |
FormGroup |
Container | Aggregates child controls; hosts cross-field validators |
FormControl<T> |
Leaf | Holds one value plus its validators and status |
FormArray |
Container | Ordered list of controls |
Validators.required / .email / .minLength / .pattern |
Sync validator | Built-in rules, analogous to native constraint attributes |
ValidatorFn |
Type | (control) => ValidationErrors | null |
AsyncValidatorFn |
Type | (control) => Observable<ValidationErrors | null> | Promise<...> |
control.errors |
State | Error object, analog of ValidityState |
control.statusChanges |
Observable | Emits VALID / INVALID / PENDING |
updateOn: 'blur' | 'submit' |
Option | When validators run — controls feedback timing |
ControlValueAccessor |
Interface | Bridges a custom component to the forms API |
Step 1 — Building a Typed Form with FormBuilder
NonNullableFormBuilder produces controls whose value type is non-nullable, eliminating the string | null noise of the default builder.
// registration-form.component.ts
import { Component, inject } from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
@Component({
selector: 'app-registration-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './registration-form.component.html',
})
export class RegistrationFormComponent {
private fb = inject(NonNullableFormBuilder);
// Each Validators entry is the framework analog of an HTML constraint attribute.
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
username: [
'',
[
Validators.required,
Validators.minLength(3),
Validators.maxLength(20),
Validators.pattern(/^[a-zA-Z0-9_]+$/),
],
],
age: [0, [Validators.required, Validators.min(18)]],
});
onSubmit(): void {
// form.valid is the framework-native checkValidity() gate.
if (this.form.invalid) {
this.form.markAllAsTouched(); // reveal errors that were never touched
this.focusFirstInvalid();
return;
}
this.submit(this.form.getRawValue()); // fully typed payload
}
private focusFirstInvalid(): void {
const firstInvalid =
document.querySelector<HTMLElement>('.ng-invalid[formControlName]');
firstInvalid?.focus();
}
}
markAllAsTouched() mirrors the native behavior where errors surface on submit even for fields the user never visited. The focusFirstInvalid() helper implements the focus-management requirement detailed in Focus Management & Keyboard Navigation — Angular does not move focus for you.
Step 2 — Custom Synchronous Validators
A ValidatorFn returns null when valid or a keyed ValidationErrors object when invalid. The key names the error so the template can select a message.
// validators/no-reserved-words.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
const RESERVED = new Set(['admin', 'root', 'system']);
export function noReservedWords(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = String(control.value ?? '').toLowerCase().trim();
if (RESERVED.has(value)) {
// The error key 'reserved' is selected in the template, like a ValidityState flag.
return { reserved: { value } };
}
return null;
};
}
Attach it alongside the built-ins: username: ['', [Validators.required, noReservedWords()]]. The pattern is identical to reading a granular flag off ValidityState; here you read control.errors?.['reserved'] instead of input.validity.customError.
Step 3 — Custom Asynchronous Validators
An AsyncValidatorFn returns an Observable or Promise that emits the same ValidationErrors | null. While it is pending, control.status is 'PENDING'. This is the framework-native form of an asynchronous server check, and it carries the same stale-response hazard — solved here with RxJS switchMap, which cancels the previous inner subscription when a new value arrives.
// validators/username-available.validator.ts
import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError, first } from 'rxjs/operators';
export function usernameAvailableValidator(): AsyncValidatorFn {
const http = inject(HttpClient);
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const value = String(control.value ?? '').trim();
if (value.length < 3) return of(null); // let sync validators own short values
// timer(300) debounces; switchMap cancels the prior request on new input.
return timer(300).pipe(
switchMap(() =>
http.get<{ available: boolean }>(`/api/username-available?u=${encodeURIComponent(value)}`),
),
map(({ available }) => (available ? null : { taken: true })),
catchError(() => of({ unverified: true })), // network failure → distinct error
first(),
);
};
}
Register async validators in the third argument slot so they run after the synchronous ones pass:
username: this.fb.control('', {
validators: [Validators.required, Validators.minLength(3), noReservedWords()],
asyncValidators: [usernameAvailableValidator()],
updateOn: 'blur', // avoid a request per keystroke; validate on blur
}),
updateOn: 'blur' is the timing lever; combined with the 300ms debounce it keeps request volume sane, matching the real-time-feedback guidance in the UX Patterns & Error State Design approaches.
Step 4 — Group-Level Cross-Field Validators
A validator attached to the FormGroup receives the group and can compare sibling controls — the canonical use is matching a password and its confirmation, covered in depth in Cross-Field Validation Strategies.
// validators/passwords-match.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const passwordsMatch: ValidatorFn = (
group: AbstractControl,
): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
if (!confirm) return null; // do not flag before the user fills confirm
return password === confirm ? null : { passwordMismatch: true };
};
Attach it as the group’s validator: this.fb.group({ ... }, { validators: passwordsMatch }). The error lands on the group (form.errors?.['passwordMismatch']), so the template selects it from the group, not an individual control.
Step 5 — ControlValueAccessor for Custom Controls
When you wrap a non-native widget (a custom toggle, a date picker), implement ControlValueAccessor so Angular’s validation engine treats it like any control — preserving the native-equivalent validity contract through a custom UI.
// star-rating.component.ts
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-star-rating',
standalone: true,
template: `
<div role="radiogroup" aria-label="Rating">
<button
*ngFor="let star of [1, 2, 3, 4, 5]"
type="button"
role="radio"
[attr.aria-checked]="star === value"
(click)="select(star)"
(blur)="onTouched()"
>★</button>
</div>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent),
multi: true,
},
],
})
export class StarRatingComponent implements ControlValueAccessor {
value = 0;
onChange: (v: number) => void = () => {};
onTouched: () => void = () => {};
writeValue(v: number): void { this.value = v ?? 0; }
registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
select(star: number): void {
this.value = star;
this.onChange(star); // pushes value into the FormControl, triggering validation
}
}
Once registered, Validators.min(1) on the control validates the custom widget exactly as it would a native <input>.
Step 6 — Accessible Error Templates
The template selects an error message by key and binds the accessibility attributes that satisfy WCAG SC 3.3.1.
<!-- registration-form.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
type="text"
formControlName="username"
[attr.aria-invalid]="form.controls.username.touched && form.controls.username.invalid"
[attr.aria-describedby]="form.controls.username.invalid ? 'username-error' : null"
/>
@if (form.controls.username.touched && form.controls.username.errors; as errors) {
<p id="username-error" class="error" role="alert">
@if (errors['required']) { Username is required. }
@else if (errors['minlength']) { At least 3 characters. }
@else if (errors['pattern']) { Letters, numbers and underscores only. }
@else if (errors['reserved']) { That username is reserved. }
@else if (errors['taken']) { That username is already taken. }
@else if (errors['unverified']) { Could not verify username, please retry. }
</p>
}
@if (form.controls.username.pending) {
<p class="hint" aria-live="polite">Checking availability…</p>
}
</div>
<button type="submit">Create Account</button>
</form>
The aria-describedby only points at the error node when an error exists, role="alert" announces it, and the pending branch uses aria-live="polite" for the async status — the same status-message pattern (WCAG SC 4.1.3) you would build manually over native validation.
Dynamic Controls with FormArray
Repeating sections — multiple email addresses, a list of tags — use FormArray, where each entry is itself a control or group carrying its own validators. The array’s validity is the conjunction of its children, so the parent form.valid gate still works unchanged.
emails = this.fb.array<FormControl<string>>([
this.fb.control('', [Validators.required, Validators.email]),
]);
addEmail(): void {
this.emails.push(this.fb.control('', [Validators.required, Validators.email]));
}
removeEmail(index: number): void {
this.emails.removeAt(index);
}
In the template, iterate the array’s controls and bind each error template per row. Because every child control exposes the same errors/touched/pending surface, the accessible rendering pattern from Step 6 applies identically to dynamic rows — no special-casing required.
Testing the Validation Logic
Validators are pure functions, so they test in isolation without rendering a component — the framework analog of unit-testing a constraint check against a mocked ValidityState.
// passwords-match.validator.spec.ts
import { FormBuilder } from '@angular/forms';
import { passwordsMatch } from './validators/passwords-match.validator';
describe('passwordsMatch', () => {
const fb = new FormBuilder();
it('returns null when fields agree', () => {
const group = fb.group(
{ password: 'abc12345', confirmPassword: 'abc12345' },
{ validators: passwordsMatch() },
);
expect(group.errors).toBeNull();
});
it('sets passwordMismatch on the group when fields differ', () => {
const group = fb.group(
{ password: 'abc12345', confirmPassword: 'xyz' },
{ validators: passwordsMatch() },
);
expect(group.errors?.['passwordMismatch']).toBe(true);
});
});
For async validators, use fakeAsync/tick to advance past the debounce timer and HttpTestingController to stub the server response, asserting the control transitions PENDING → INVALID with the expected error key. End-to-end, a Playwright run should assert aria-invalid, the role="alert" message text, and focus movement on a failed submit — the same accessibility contract you would verify for native validation.
Common Gotchas
1. Async validator fires on every keystroke. Without updateOn: 'blur' and a debounce, you flood the server. Add both.
// Before — a request per character
asyncValidators: [usernameAvailableValidator()]
// After — debounced inside the validator + updateOn blur
{ asyncValidators: [usernameAvailableValidator()], updateOn: 'blur' }
2. Errors never appear because the field is untouched. On submit, call form.markAllAsTouched() before reading validity, or errors stay hidden.
3. Group-level error read from a child control. A passwordMismatch error lives on the group. Reading form.controls.confirmPassword.errors returns null; read form.errors instead.
4. mergeMap instead of switchMap in async validators. mergeMap lets stale responses resolve out of order and overwrite the current state. Use switchMap so a new value cancels the prior request.
5. Forgetting multi: true on NG_VALUE_ACCESSOR. Without it, Angular replaces the provider array and your custom control silently fails to bind.
Browser Compatibility
| Capability | Chrome | Firefox | Safari | Notes |
|---|---|---|---|---|
| Angular reactive forms | 64+ | 60+ | 12+ | Validation runs in JS, engine-agnostic |
AbortController (if using fetch) |
66+ | 57+ | 12.1+ | RxJS switchMap is the usual cancellation path |
aria-live / role="alert" |
All | All | All | Core a11y contract |
@if / @for control flow |
— | — | — | Requires Angular 17+; use *ngIf/*ngFor on 16 |
Frequently Asked Questions
How is control.errors related to native ValidityState?
They are direct analogs. Native validation exposes flags like valueMissing on input.validity; Angular exposes a keyed object like { required: true } on control.errors. In both cases you read the specific key to choose a message, and form.valid plays the role of checkValidity().
When should I use updateOn: 'blur' versus the default?
Use 'blur' for fields with expensive async validators so you validate once the user leaves the field rather than on every keystroke. Keep the default 'change' for cheap synchronous rules where immediate feedback helps. 'submit' defers all validation to form submission.
Why does my password-match error not show on the confirm field?
Group-level validators set the error on the FormGroup, not on a child control. Read form.errors?.['passwordMismatch'] and render the message under the confirm field, or copy the error onto the control with setErrors if you prefer it attached there.
How do I prevent stale async validation responses?
Use RxJS switchMap inside the AsyncValidatorFn. It cancels the previous inner request when a new value arrives, so a slow early response can never overwrite a fast later one — the same guarantee AbortController gives a raw fetch-based check.
Related Guides
- Angular Cross-Field Password-Match Validator — a complete group-level matcher with accessible display
- React Hook Form Validation — the equivalent pattern in React
- Vue VeeValidate Validation — the equivalent pattern in Vue
- Cross-Field Validation Strategies — the framework-agnostic theory behind group validators
- Asynchronous Server Checks — race conditions, debouncing, and cancellation for async rules
- Constraint Validation API Deep Dive — the native validity model reactive forms mirror
← Back to Framework Integration Patterns