Custom Validators
Angular’s built-in validators cover the common cases — required, email, min length — but real applications need domain rules the framework can’t know about, such as “the password and confirmation must match” or “this username may not contain spaces”. A custom validator is just a function that inspects a control’s value and returns either null (valid) or an errors object (invalid). Because the contract is so small, you can write reusable, parameterised, and cross-field validators with plain TypeScript and no special Angular machinery.
The ValidatorFn contract
Every synchronous validator implements the ValidatorFn type: a function that takes an AbstractControl and returns ValidationErrors | null. Returning null means the control passes; returning an object marks it invalid, and that object’s keys become the error keys you read later via control.hasError('key').
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function noWhitespace(control: AbstractControl): ValidationErrors | null {
const value = control.value as string;
const isValid = value == null || !/\s/.test(value);
return isValid ? null : { whitespace: true };
}
You attach a ValidatorFn exactly like a built-in one, passing the function reference (not calling it):
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { noWhitespace } from './validators';
@Component({
selector: 'app-signup',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './signup.component.html',
})
export class SignupComponent {
private fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
username: ['', [Validators.required, noWhitespace]],
});
}
Parameterised validators (validator factories)
A bare function works when the rule has no configuration. When the rule needs an argument — a forbidden value, a regex, a numeric range — write a factory that closes over the argument and returns a ValidatorFn. This is the same pattern Validators.minLength(8) uses internally.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenValue(forbidden: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const matches = control.value === forbidden;
return matches ? { forbidden: { value: control.value } } : null;
};
}
Note that the factory is called when you attach it, because it returns the actual validator:
username: ['', [Validators.required, forbiddenValue('admin')]],
Returning a rich error payload ({ forbidden: { value } }) instead of a bare true lets the template show context-specific messages.
Cross-field validators
A single-control validator can only see one value, so it can’t compare two fields. To validate across controls — password confirmation, date ranges, “at least one of these is filled” — attach the validator to the parent FormGroup instead. The control passed in is then the group, and you read its children with control.get(...).
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const passwordsMatch: ValidatorFn = (
group: AbstractControl,
): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirm')?.value;
if (!password || !confirm) return null;
return password === confirm ? null : { passwordsMismatch: true };
};
Attach it through the group’s options object, the second argument to fb.group:
form = this.fb.nonNullable.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirm: ['', Validators.required],
},
{ validators: passwordsMatch },
);
The error now lives on the group, not on either child control, so you read it from the group in the template.
<form [formGroup]="form">
<input type="password" formControlName="password" placeholder="Password" />
<input type="password" formControlName="confirm" placeholder="Confirm" />
@if (form.hasError('passwordsMismatch') && form.get('confirm')?.touched) {
<p class="error">Passwords do not match.</p>
}
</form>
Group-level errors do not mark the child controls invalid, only the group. If you want the confirm field itself to show an invalid state, call
group.get('confirm')?.setErrors({ passwordsMismatch: true })from within the validator — but always return the error from the function too, or Angular will clear it on the next run.
Reading errors in the template
Whatever object your validator returns surfaces through control.errors and the hasError helper. Pair it with touched/dirty so messages appear only after the user has interacted.
@let username = form.controls.username;
@if (username.invalid && (username.dirty || username.touched)) {
@if (username.hasError('required')) {
<p class="error">Username is required.</p>
} @else if (username.hasError('whitespace')) {
<p class="error">No spaces allowed.</p>
} @else if (username.hasError('forbidden')) {
<p class="error">"{{ username.getError('forbidden').value }}" is reserved.</p>
}
}
Output:
form.controls.username.errors → { forbidden: { value: 'admin' } }
form.valid → false
Sync vs async at a glance
| Aspect | Synchronous (ValidatorFn) | Asynchronous (AsyncValidatorFn) |
|---|---|---|
| Return type | ValidationErrors | null | Observable/Promise of the same |
| Runs | Immediately on every value change | After sync validators pass |
| Use for | Format, ranges, cross-field rules | Server checks (e.g. unique email) |
| Attached as | validators option | asyncValidators option |
| Pending state | Never | Sets control.pending while running |
If your rule needs a network round-trip, reach for an async validator instead; everything self-contained belongs in a synchronous ValidatorFn.
Best Practices
- Keep validators pure: derive the result only from
control.value, with no side effects, so they’re predictable and reusable across forms. - Use a factory function whenever the rule takes configuration, mirroring
Validators.minLength(n). - Return descriptive error objects (
{ range: { min, max, actual } }) rather thantrue, so templates can render precise messages. - Put cross-field rules on the
FormGroup, not the children, and read the error from the group in the template. - Guard against
null/empty values early and returnnull— letValidators.requiredown the “is it filled” question so validators compose cleanly. - Export validators from a shared
validators.tsmodule and unit-test them as plain functions; they need noTestBed.