Validation is the contract between your form and your data model — it ensures that data reaching the server is structurally correct, business-rule-compliant, and safe to process. Angular’s reactive forms support three kinds of validators: built-in synchronous validators (required, minlength, pattern), custom synchronous validators (future date, password match, conditional rules), and async validators (uniqueness checks, server-side validation). Understanding when each applies, how cross-field validation works, how to display errors cleanly, and how to optimise async validators to avoid hammering your API separates professional Angular forms from fragile ones.
Built-in Validators Reference
| Validator | Error Key | Checks |
|---|---|---|
Validators.required |
required |
Value is not empty/null/undefined |
Validators.requiredTrue |
required |
Value is exactly true (checkbox) |
Validators.minLength(n) |
minlength |
String length >= n |
Validators.maxLength(n) |
maxlength |
String length <= n |
Validators.min(n) |
min |
Numeric value >= n |
Validators.max(n) |
max |
Numeric value <= n |
Validators.email |
email |
Valid email format |
Validators.pattern(regex) |
pattern |
Value matches regex |
Validators.nullValidator |
(none) | Always valid — placeholder |
Validators.compose([...]) |
Any failing validator’s key | Run multiple validators, fail on first error |
Validators.composeAsync([...]) |
Any failing validator’s key | Run multiple async validators |
Validator Function Signatures
| Type | Signature | Returns |
|---|---|---|
| Synchronous validator | (control: AbstractControl) => ValidationErrors | null |
Error object or null (valid) |
| Sync validator factory | (config) => (control: AbstractControl) => ValidationErrors | null |
Validator function |
| Async validator | (control: AbstractControl) => Observable<ValidationErrors | null> | Promise<...> |
Observable/Promise of errors or null |
| Cross-field validator | Applied to FormGroup instead of FormControl |
Error on the group, not the individual controls |
FormGroup — not individual controls. A password confirmation validator compares group.get('password')?.value to group.get('confirmPassword')?.value and returns an error on the group: { passwordMismatch: true }. To display this error in the template, read it from the group: form.errors?.['passwordMismatch'] — not from either individual control. The individual controls only show their own validators’ errors.debounceTime() and first(). debounceTime(400) prevents an API call on every keystroke. first() ensures the Observable completes after emitting one value — without it, Angular’s async validator system may not correctly finalise the validation state. Also check if (!control.value) return of(null) at the start to avoid calling the API for empty values (which are handled by the synchronous required validator anyway).pending state while the async validator runs. Your template should check ctrl.pending alongside ctrl.invalid and ctrl.touched — do not disable the submit button solely based on form.invalid when async validators are running, because the form is neither invalid nor valid during the pending state. Check form.invalid || form.pending for the submit button’s disabled state.Complete Validation Examples
import {
AbstractControl, ValidationErrors, ValidatorFn,
AsyncValidatorFn, Validators,
} from '@angular/forms';
import { inject } from '@angular/core';
import { Observable, of, timer } from 'rxjs';
import { debounceTime, switchMap, map, first } from 'rxjs/operators';
// ── Custom synchronous validators ────────────────────────────────────────
// Date must be in the future
export function futureDateValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) return null; // empty is valid (handled by required)
const date = new Date(control.value);
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today ? { pastDate: { value: control.value } } : null;
};
}
// Date must be before another control's date
export function beforeDateValidator(otherControlName: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) return null;
const parent = control.parent;
if (!parent) return null;
const otherControl = parent.get(otherControlName);
if (!otherControl?.value) return null;
const thisDate = new Date(control.value);
const otherDate = new Date(otherControl.value);
return thisDate >= otherDate
? { dateNotBefore: { value: control.value, compareWith: otherControl.value } }
: null;
};
}
// URL format
export function urlValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!control.value) return null;
try {
new URL(control.value);
return null;
} catch {
return { invalidUrl: true };
}
};
}
// ── Cross-field validator (applied to FormGroup) ──────────────────────────
// Password and confirmPassword must match
export function passwordMatchValidator(
passwordKey = 'password',
confirmKey = 'confirmPassword',
): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get(passwordKey)?.value;
const confirm = group.get(confirmKey)?.value;
if (!password || !confirm) return null;
return password === confirm ? null : { passwordMismatch: true };
};
}
// At least one of multiple fields must be filled
export function atLeastOneRequired(...fieldNames: string[]): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const hasValue = fieldNames.some(name => {
const ctrl = group.get(name);
return ctrl?.value !== null && ctrl?.value !== '' && ctrl?.value !== undefined;
});
return hasValue ? null : { atLeastOneRequired: { fields: fieldNames } };
};
}
// ── Async validators ──────────────────────────────────────────────────────
// Check email uniqueness against the API
export function uniqueEmailValidator(excludeEmail?: string): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value || !control.value.includes('@')) return of(null);
if (control.value === excludeEmail) return of(null); // own email is ok
return timer(400).pipe( // debounce — use timer for cleaner cancellation
switchMap(() =>
inject(UserService).checkEmailExists(control.value)
),
map(exists => exists ? { emailTaken: true } : null),
first(),
);
};
}
// Check username availability
export function uniqueUsernameValidator(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value || control.value.length < 3) return of(null);
return timer(500).pipe(
switchMap(() => inject(UserService).checkUsernameAvailable(control.value)),
map(available => available ? null : { usernameTaken: true }),
first(),
);
};
}
// ── Complete registration form example ───────────────────────────────────
import { NonNullableFormBuilder } from '@angular/forms';
const fb = inject(NonNullableFormBuilder);
const registerForm = fb.group({
name: ['', [
Validators.required,
Validators.minLength(2),
Validators.maxLength(100),
]],
email: ['', {
validators: [Validators.required, Validators.email],
asyncValidators: [uniqueEmailValidator()],
updateOn: 'blur', // only validate on blur — reduces API calls
}],
username: ['', {
validators: [Validators.required, Validators.pattern(/^[a-z0-9_]{3,30}$/)],
asyncValidators: [uniqueUsernameValidator()],
updateOn: 'blur',
}],
password: ['', [
Validators.required,
Validators.minLength(8),
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/), // upper + lower + digit
]],
confirmPassword: ['', Validators.required],
agreeToTerms: [false, Validators.requiredTrue],
dateRange: fb.group({
startDate: [''],
endDate: [''],
}, {
validators: [atLeastOneRequired('startDate', 'endDate')],
}),
}, {
validators: [passwordMatchValidator()], // cross-field validator on the group
});
// ── Adding/removing validators dynamically ────────────────────────────────
// Make dueDate required for high priority tasks
this.priorityCtrl.valueChanges.subscribe(priority => {
const dueDateCtrl = this.form.get('dueDate')!;
if (priority === 'high') {
dueDateCtrl.addValidators(Validators.required);
} else {
dueDateCtrl.removeValidators(Validators.required);
}
dueDateCtrl.updateValueAndValidity();
});
<!-- Template — displaying validation errors cleanly -->
<!-- Email with async validator pending state -->
<div class="field">
<label for="email">Email *</label>
<div class="input-wrapper">
<input id="email" type="email" formControlName="email"
[class.input--valid]="emailCtrl.valid && emailCtrl.touched"
[class.input--invalid]="emailCtrl.invalid && emailCtrl.touched">
<span *ngIf="emailCtrl.pending" class="loading-dots">Checking...</span>
<span *ngIf="emailCtrl.valid && emailCtrl.touched && !emailCtrl.pending"
class="valid-icon">✓</span>
</div>
<div class="errors" *ngIf="emailCtrl.invalid && emailCtrl.touched">
<p *ngIf="emailCtrl.errors?.['required']">Email is required.</p>
<p *ngIf="emailCtrl.errors?.['email']">Please enter a valid email address.</p>
<p *ngIf="emailCtrl.errors?.['emailTaken']">This email is already registered.</p>
</div>
</div>
<!-- Password match cross-field error -->
<div class="errors" *ngIf="form.errors?.['passwordMismatch'] && form.get('confirmPassword')?.touched">
<p>Passwords do not match.</p>
</div>
<!-- Submit button respects pending state -->
<button type="submit"
[disabled]="form.invalid || form.pending || submitting()">
{{ submitting() ? 'Registering...' : 'Create Account' }}
</button>
How It Works
Step 1 — Validators Are Called on Every Value Change
Synchronous validators run immediately whenever the control’s value changes (or on blur if updateOn: 'blur'). They receive the AbstractControl and must return either null (valid) or an errors object like { required: true }. Multiple validators are composed — Angular runs them all and merges their errors. The order matters only for readability; all errors are always computed.
Step 2 — Async Validators Run After Sync Validators Pass
Angular only runs async validators when all sync validators return null (the field is synchronously valid). This is an optimisation — there is no point checking email uniqueness if the email format is invalid. The control’s status becomes 'PENDING' while async validators run, then transitions to 'VALID' or 'INVALID' when they complete. Using updateOn: 'blur' prevents async validators from running on every keystroke.
Step 3 — Cross-Field Validators Read Sibling Controls
A validator applied to a FormGroup receives the group as control. It can access any control in the group via control.get('fieldName'). Errors returned by a group-level validator appear on group.errors, not on individual controls. This is why form.errors?.['passwordMismatch'] works but form.get('confirmPassword')?.errors?.['passwordMismatch'] does not — the error is on the parent group.
Step 4 — timer() Provides Clean Debouncing for Async Validators
Using timer(400) at the start of an async validator creates a delay before the API call. When Angular re-runs the async validator (because the value changed again), it cancels the pending Observable and creates a new one — effectively debouncing without needing an explicit debounceTime operator. This approach integrates cleanly with Angular’s validator cancellation mechanism, whereas debounceTime inside the validator pipe can behave unexpectedly during cancellation.
Step 5 — addValidators / removeValidators Enable Dynamic Validation Rules
Calling control.addValidators(validator) adds a validator to the existing list; removeValidators(validator) removes it by reference (the function reference must match). You must call control.updateValueAndValidity() after adding or removing validators to trigger re-validation with the new ruleset. This enables validation rules that depend on the values of other controls — making dueDate required only for high-priority tasks, for example.
Common Mistakes
Mistake 1 — Cross-field validator errors checked on individual controls
❌ Wrong — passwordMismatch is on the group, not the confirmPassword control:
<p *ngIf="form.get('confirmPassword')?.errors?.['passwordMismatch']">Mismatch!</p>
<!-- Always false — error is on form, not on confirmPassword -->
✅ Correct — read cross-field errors from the group:
<p *ngIf="form.errors?.['passwordMismatch'] && form.get('confirmPassword')?.touched">Mismatch!</p>
Mistake 2 — Not calling updateValueAndValidity after dynamic validator changes
❌ Wrong — new validator added but form status not updated:
dueDateCtrl.addValidators(Validators.required);
// Form still shows as valid! The new validator hasn't run yet.
✅ Correct — always trigger revalidation:
dueDateCtrl.addValidators(Validators.required);
dueDateCtrl.updateValueAndValidity(); // runs all validators with new rule
Mistake 3 — Async validator without first() — never completes
❌ Wrong — Observable never completes, Angular waits forever:
return (control) => {
return this.userService.checkEmail(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
// Missing first() — Angular validator stays 'pending' forever!
);
};
✅ Correct — use first() to complete after one emission:
return (control) => {
return this.userService.checkEmail(control.value).pipe(
map(exists => exists ? { emailTaken: true } : null),
first(), // complete after receiving one value — Angular can proceed
);
};
Quick Reference
| Task | Code |
|---|---|
| Custom sync validator | (ctrl): ValidationErrors | null => ctrl.value ? null : { myError: true } |
| Custom async validator | (ctrl): Observable<...> => timer(400).pipe(switchMap(...), map(...), first()) |
| Cross-field validator | Apply to FormGroup: fb.group({...}, { validators: [crossValidator] }) |
| Update on blur | new FormControl('', { validators: [...], updateOn: 'blur' }) |
| Add validator dynamically | ctrl.addValidators(fn); ctrl.updateValueAndValidity() |
| Check pending | ctrl.pending or form.pending |
| Show cross-field error | form.errors?.['errorKey'] |
| Compose validators | Validators.compose([v1, v2, v3]) |
| Disable submit while pending | [disabled]="form.invalid || form.pending" |