Reader Stacks

Angular Reactive Forms: Conditional Validation and Custom Validators

Beyond the built-in Validators, reactive forms support validators that depend on another field's value, and fully custom validation functions for anything the built-ins don't cover.

Angular's built-in Validators (required, minLength, pattern) cover the common cases, but real forms often need validation that depends on another field's value, or a rule the built-ins simply don't express — that's what conditional and custom validators are for.

Basic reactive form setup, as a starting point

this.form = this.fb.group({
    accountType: ['personal'],
    companyName: [''],
    email: ['', [Validators.required, Validators.email]],
});

Conditional validation: a field required only under a condition

this.form.get('accountType')?.valueChanges.subscribe(type => {
    const companyName = this.form.get('companyName');

    if (type === 'business') {
        companyName?.setValidators([Validators.required]);
    } else {
        companyName?.clearValidators();
    }

    companyName?.updateValueAndValidity();
});

updateValueAndValidity() is easy to forget — without it, calling setValidators() or clearValidators() updates the validator list but doesn't actually re-run validation against the field's current value.

Writing a custom validator function

function forbiddenUsernameValidator(forbidden: string[]): ValidatorFn {
    return (control: AbstractControl): ValidationErrors | null => {
        return forbidden.includes(control.value) ? { forbiddenUsername: true } : null;
    };
}
this.form = this.fb.group({
    username: ['', [Validators.required, forbiddenUsernameValidator(['admin', 'root'])]],
});

A custom validator is just a function matching the ValidatorFn signature — it receives the control and returns either null (valid) or an error object (invalid), exactly like the built-in validators Angular ships with.

A group-level custom validator (comparing two fields)

function dateRangeValidator(group: AbstractControl): ValidationErrors | null {
    const start = group.get('startDate')?.value;
    const end = group.get('endDate')?.value;

    if (start && end && new Date(start) > new Date(end)) {
        return { dateRangeInvalid: true };
    }
    return null;
}
this.form = this.fb.group({
    startDate: [''],
    endDate: [''],
}, { validators: dateRangeValidator });

Group-level validators (passed as the second argument to fb.group()) are the correct place for any rule that compares two or more fields against each other — an individual field-level validator only ever sees that one field's own value.

An asynchronous validator (checking against a server)

function uniqueEmailValidator(userService: UserService): AsyncValidatorFn {
    return (control: AbstractControl) => {
        return userService.checkEmailExists(control.value).pipe(
            map(exists => (exists ? { emailTaken: true } : null))
        );
    };
}
this.form = this.fb.group({
    email: ['', [Validators.required, Validators.email], [uniqueEmailValidator(this.userService)]],
});

Async validators go in a third array argument (after the synchronous validators array) and return an Observable or Promise — Angular waits for it to resolve before marking the field's async validation status as complete, showing a PENDING state on the control in the meantime.

Displaying the right error message

That username isn't available.
Checking availability...

Why extracting reusable validators into their own file pays off

Once a project has more than one or two custom validators, moving them into a shared validators/ file (rather than defining them inline in each component) makes them testable in isolation and reusable across every form that needs the same rule.