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.