Reactive forms build a form's structure and validation rules directly in the component class rather than the template — genuinely more testable and scalable than template-driven forms for anything beyond a very simple form, and a custom validator follows a small, fixed contract that makes it composable with Angular's built-in ones.
Building a basic reactive form
form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
age: [null, [Validators.required, Validators.min(18)]],
});
Writing a custom validator function
function forbiddenUsernameValidator(control: AbstractControl): ValidationErrors | null {
const forbidden = ['admin', 'root', 'superuser'];
return forbidden.includes(control.value?.toLowerCase()) ? { forbiddenUsername: true } : null;
}
form = this.fb.group({
username: ['', [Validators.required, forbiddenUsernameValidator]],
});
A validator function follows one fixed contract: it takes an AbstractControl and returns either null (valid) or an object describing the specific error (invalid) — this exact shape is what makes a custom validator composable alongside Angular's built-in ones in the same array, with no special wiring needed to combine them.
A custom validator that needs a configurable parameter
function minWordCountValidator(minWords: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const wordCount = control.value?.trim().split(/\s+/).filter(Boolean).length ?? 0;
return wordCount < minWords ? { minWordCount: { required: minWords, actual: wordCount } } : null;
};
}
description: ['', minWordCountValidator(10)]
Wrapping the actual validator function inside an outer function that accepts a parameter (a "validator factory") is the standard pattern for a configurable custom validator — the outer function's job is only to close over the parameter and return the real validator function Angular actually calls.
A cross-field validator, applied at the form group level
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirmPassword = group.get('confirmPassword')?.value;
return password === confirmPassword ? null : { passwordMismatch: true };
}
form = this.fb.group({
password: ['', Validators.required],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });
A cross-field validator (comparing two sibling fields, like a password confirmation) needs to be applied to the entire FormGroup, not one individual field's control — this is exactly why it's passed as the group's own validators option rather than inside a single field's validator array.
Displaying a custom validator's error message
That username is not allowed.
Passwords do not match.
An asynchronous validator, for a check requiring a server call
function uniqueEmailValidator(userService: UserService): AsyncValidatorFn {
return (control: AbstractControl) => {
return userService.checkEmailExists(control.value).pipe(
map(exists => (exists ? { emailTaken: true } : null))
);
};
}
email: ['', [Validators.required, Validators.email], [uniqueEmailValidator(this.userService)]]
An async validator is passed as the third array in the control's configuration, distinct from the synchronous validators array — necessary since checking whether an email is already taken requires an actual API call, which can't happen synchronously the way a length or pattern check can.