Custom Validators in Angular
Built-in validators cover common cases, but real applications always need custom rules — banned words, specific date ranges, unique usernames, business-rule constraints. Angular makes it straightforward to write reusable validator functions that plug directly into both reactive and template-driven forms.
Validator Function Signature
A validator function receives an AbstractControl and returns either null (valid) or a ValidationErrors object (invalid). The keys of that object become the error keys you check in templates.
import { AbstractControl, ValidationErrors } from '@angular/forms';
// ValidatorFn type signature:
// (control: AbstractControl) => ValidationErrors | null
// Simple example: no spaces allowed
export function noSpacesValidator(control: AbstractControl): ValidationErrors | null {
const value: string = control.value ?? '';
return value.includes(' ') ? { noSpaces: true } : null;
}
// Usage in a reactive form
import { FormControl } from '@angular/forms';
const username = new FormControl('', [noSpacesValidator]);
// Error key in template
// *ngIf="username.hasError('noSpaces')" → "noSpaces": trueValidators with Parameters (Factory Pattern)
When a validator needs configuration, use a factory function that returns the validator.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Factory: forbid specific words
export function forbiddenWordsValidator(words: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value: string = (control.value ?? '').toLowerCase();
const found = words.find(word => value.includes(word.toLowerCase()));
return found ? { forbiddenWord: { word: found } } : null;
};
}
// Usage
import { FormControl } from '@angular/forms';
const bio = new FormControl('', [
forbiddenWordsValidator(['spam', 'admin', 'test']),
]);
// In template:
// *ngIf="bio.hasError('forbiddenWord')"
// bio.getError('forbiddenWord').word → the offending word// Factory: minimum password strength
export function passwordStrengthValidator(minScore: number): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value: string = control.value ?? '';
let score = 0;
if (value.length >= 8) score++;
if (/[A-Z]/.test(value)) score++;
if (/[a-z]/.test(value)) score++;
if (/[0-9]/.test(value)) score++;
if (/[^A-Za-z0-9]/.test(value)) score++;
return score >= minScore ? null : { passwordStrength: { actual: score, required: minScore } };
};
}
const password = new FormControl('', [passwordStrengthValidator(3)]);Cross-Field Validators on FormGroup
When a rule spans two or more fields, apply the validator to the FormGroup. The whole group is the AbstractControl the function receives.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Passwords must match
export const confirmPasswordValidator: ValidatorFn = (
group: AbstractControl
): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
};
// Date range: start must be before end
export const dateRangeValidator: ValidatorFn = (
group: AbstractControl
): ValidationErrors | null => {
const start = new Date(group.get('startDate')?.value);
const end = new Date(group.get('endDate')?.value);
if (!start || !end || isNaN(start.getTime()) || isNaN(end.getTime())) return null;
return start < end ? null : { invalidDateRange: true };
};
// Apply to a FormGroup
import { FormGroup, FormControl, Validators } from '@angular/forms';
const resetForm = new FormGroup(
{
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
confirmPassword: new FormControl('', Validators.required),
},
{ validators: confirmPasswordValidator }
);<!-- Group-level error is on the group, not on a single control -->
<p *ngIf="resetForm.hasError('passwordMismatch') && resetForm.touched">
Passwords do not match.
</p>Async Custom Validators
Async validators perform server-side checks. They must return an Observable or Promise that emits ValidationErrors | null.
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
AbstractControl,
AsyncValidatorFn,
ValidationErrors,
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import { map, catchError, debounceTime, switchMap, first } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class UsernameAvailabilityValidator {
constructor(private http: HttpClient) {}
validate(): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
if (!control.value || control.value.length < 3) {
return of(null); // don't call API for trivially invalid values
}
return of(control.value).pipe(
debounceTime(400),
switchMap(username =>
this.http.get<{ available: boolean }>(
`/api/users/available?username=${encodeURIComponent(username)}`
)
),
map(res => res.available ? null : { usernameTaken: true }),
catchError(() => of(null)),
first(),
);
};
}
}// Usage in component
import { inject } from '@angular/core';
export class RegisterComponent {
private usernameValidator = inject(UsernameAvailabilityValidator);
form = new FormGroup({
username: new FormControl('', {
validators: [Validators.required, Validators.minLength(3)],
asyncValidators: [this.usernameValidator.validate()],
updateOn: 'blur',
}),
});
}Custom Validator Directives for Template-Driven Forms
To use a custom validator in template-driven forms, wrap it in a directive that provides itself under the NG_VALIDATORS token.
import { Directive, Input } from '@angular/core';
import {
AbstractControl,
NG_VALIDATORS,
ValidationErrors,
Validator,
} from '@angular/forms';
@Directive({
standalone: true,
selector: '[appForbiddenWord]',
providers: [
{
provide: NG_VALIDATORS,
useExisting: ForbiddenWordDirective,
multi: true, // multiple validators can be registered
},
],
})
export class ForbiddenWordDirective implements Validator {
@Input('appForbiddenWord') forbiddenWord = '';
validate(control: AbstractControl): ValidationErrors | null {
const value = (control.value ?? '').toLowerCase();
return value.includes(this.forbiddenWord.toLowerCase())
? { forbiddenWord: { word: this.forbiddenWord } }
: null;
}
}<!-- Use as an attribute in a template-driven form -->
<input
name="bio"
ngModel
#bioField="ngModel"
appForbiddenWord="admin"
/>
<p *ngIf="bioField.hasError('forbiddenWord')">
The word "{{ bioField.getError('forbiddenWord').word }}" is not allowed.
</p>Async Directive Validator
import { Directive, inject } from '@angular/core';
import {
AbstractControl,
AsyncValidator,
NG_ASYNC_VALIDATORS,
ValidationErrors,
} from '@angular/forms';
import { Observable } from 'rxjs';
import { UsernameAvailabilityValidator } from './username-availability.validator';
@Directive({
standalone: true,
selector: '[appUniqueUsername]',
providers: [
{
provide: NG_ASYNC_VALIDATORS,
useExisting: UniqueUsernameDirective,
multi: true,
},
],
})
export class UniqueUsernameDirective implements AsyncValidator {
private svc = inject(UsernameAvailabilityValidator);
validate(control: AbstractControl): Observable<ValidationErrors | null> {
return this.svc.validate()(control) as Observable<ValidationErrors | null>;
}
}Composing Multiple Custom Validators
// Combine with built-in validators freely
const usernameControl = new FormControl('', {
validators: [
Validators.required,
Validators.minLength(3),
Validators.maxLength(20),
noSpacesValidator,
forbiddenWordsValidator(['admin', 'root', 'system']),
],
asyncValidators: [this.usernameValidator.validate()],
updateOn: 'blur',
});Best Practices
Return null for valid, a ValidationErrors object for invalid — never return undefined or false
Use factory functions whenever your validator needs configuration parameters
Apply cross-field validators to the FormGroup, not to individual controls
Debounce async validators (400ms is a good default) to avoid hammering the server
Keep validator functions pure — no side effects, no dependency injection directly inside the function
For injectable dependencies (HttpClient), wrap the validator in a service and return the ValidatorFn from a method
Set updateOn: "blur" or updateOn: "submit" for async validators to reduce network requests
null (valid) or a meaningful error object. An unhandled exception inside a validator will crash the change detection cycle.Testing Custom Validators
import { FormControl } from '@angular/forms';
import { noSpacesValidator, forbiddenWordsValidator } from './validators';
describe('noSpacesValidator', () => {
it('passes for values without spaces', () => {
const ctrl = new FormControl('helloworld');
expect(noSpacesValidator(ctrl)).toBeNull();
});
it('fails for values with spaces', () => {
const ctrl = new FormControl('hello world');
expect(noSpacesValidator(ctrl)).toEqual({ noSpaces: true });
});
it('passes for empty value', () => {
const ctrl = new FormControl('');
expect(noSpacesValidator(ctrl)).toBeNull();
});
});
describe('forbiddenWordsValidator', () => {
const validator = forbiddenWordsValidator(['spam', 'admin']);
it('fails when value contains a forbidden word', () => {
const ctrl = new FormControl('I am admin');
const result = validator(ctrl);
expect(result).toEqual({ forbiddenWord: { word: 'admin' } });
});
it('passes for allowed values', () => {
const ctrl = new FormControl('hello angular');
expect(validator(ctrl)).toBeNull();
});
});FormControl with a test value and calling the function directly. This makes your validation unit tests very fast.Summary: Custom validators are functions that receive an AbstractControl and return null or a ValidationErrors object. Use factory functions for parameterized validators, apply group-level validators to FormGroup for cross-field rules, wrap validators in a directive with NG_VALIDATORS for template-driven forms, and use services returning AsyncValidatorFn for server-side checks.