Angular's reactive forms module ships a small set of built-in validators and a clean pattern for writing your own — most real forms end up needing both, plus at least one cross-field check like "password confirmation must match."
1. Built-in validators
import { FormControl, Validators } from '@angular/forms';
email = new FormControl('', [Validators.required, Validators.email]);
age = new FormControl('', [Validators.min(18), Validators.max(120)]);
Each control exposes .errors (an object keyed by validator name, or null when valid) and status flags like .invalid, .touched, and .dirty — the last two matter because you generally want to show an error only after the user has interacted with the field, not on first render.
<input formControlName="email">
<small *ngIf="email.invalid && email.touched">
Enter a valid email address
</small>
2. Writing a custom validator
A validator is just a function that takes an AbstractControl and returns either null (valid) or an errors object:
import { AbstractControl, ValidationErrors } from '@angular/forms';
function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
const hasSpace = (control.value ?? '').includes(' ');
return hasSpace ? { noSpaces: true } : null;
}
username = new FormControl('', [Validators.required, noSpacesValidator]);
For a validator that needs a parameter (a minimum word count, a forbidden list), write a factory function that returns the validator instead of hardcoding the value:
function forbiddenValueValidator(forbidden: string[]) {
return (control: AbstractControl): ValidationErrors | null =>
forbidden.includes(control.value) ? { forbidden: true } : null;
}
username = new FormControl('', [forbiddenValueValidator(['admin', 'root'])]);
3. Cross-field validation
A validator on a single FormControl can't see other controls — for password confirmation you need a validator on the parent FormGroup instead, so it can read both fields:
function passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordsMismatch: true };
}
form = new FormGroup({
password: new FormControl(''),
confirmPassword: new FormControl(''),
}, { validators: passwordsMatchValidator });
The resulting error lands on the group, not on either individual control — so the template check is form.errors?.['passwordsMismatch'], not something read off confirmPassword directly. This trips people up because the natural instinct is to look for the error where the mismatched field is, not on the parent.
4. Conditional validation
Some fields should only be required in certain states — a "company name" field that's required only when "I'm ordering as a business" is checked. Toggle validators at runtime with setValidators and re-run validation with updateValueAndValidity:
form.get('isBusiness')?.valueChanges.subscribe((isBusiness) => {
const companyName = form.get('companyName');
if (isBusiness) {
companyName?.setValidators([Validators.required]);
} else {
companyName?.clearValidators();
}
companyName?.updateValueAndValidity();
});
Forgetting updateValueAndValidity() is the most common bug here — setValidators replaces the validator function, but Angular doesn't automatically re-check the control's current value against it until you call that method.