Form Validation — Built-in, Custom Synchronous, and Async Validators

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
Note: Cross-field validators are attached to a 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.
Tip: Async validators should always include 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).
Warning: Controls with async validators show a 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"

🧠 Test Yourself

An async email validator checks uniqueness. In the template, the submit button should be disabled while the async check is running. What state property indicates the async validator is in-flight?