Angular 32 🅰️ Form Validation and Custom Validators
Angular provides a validation system built on the AbstractControl class, which FormControl, FormGroup, and FormArray all extend . Every validator is a function that receives a control and returns either null (valid) or a ValidationErrors object (invalid) . Built-in validators handle common cases — required, minLength, email, pattern — but real applications almost always need custom rules: passwords must match, usernames cannot be taken, dates must be in a range, or a value must satisfy a business constraint. This chapter covers the full validation system: how validators are structured, how to build synchronous custom validators, how to wire them into both reactive and template-driven forms, how to handle cross-field validation, and how to implement asynchronous validators that check with a server. It builds directly on the form foundations from Angular 31, and the goal is a working command of Angular’s validator machinery.
Key point: A validator is a function (control: AbstractControl) => ValidationErrors | null. The error object’s keys become entries in the control’s errors property — { forbiddenName: { value: "admin" } } is read as control.errors?.['forbiddenName'] . Custom validators are normal functions for reactive forms and directive-wrapped functions for template-driven forms. Cross-field validation runs on the parent FormGroup and reads sibling controls with control.get('name') . Async validators return a Promise or Observable that resolves to errors or null, and the control enters a pending state while waiting .
The validator contract
Every validator in Angular — built-in or custom — satisfies the same interface. It receives an AbstractControl and returns either null or a ValidationErrors object. The AbstractControl is the base class; a FormControl, FormGroup, or FormArray can all be passed to a validator, and the validator can inspect whichever properties it needs .
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const noWhitespaceValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const isWhitespace = (control.value || '').trim().length === 0;
return isWhitespace ? { whitespace: true } : null;
};
The error key — whitespace here — is what the template checks. control.errors?.['whitespace'] is truthy when the validator failed. The value can carry details: { minlength: { requiredLength: 3, actualLength: 1 } } is how Angular’s built-in minLength reports the constraint .
Why the return type is ValidationErrors | null. Returning null means “this validator has nothing to say — the control is valid as far as I am concerned.” Returning an object means “this control is invalid, and here is why.” Multiple validators on the same control each return independently, and Angular merges the results into a single errors object. If any validator returns a non-null error, the control is invalid.
Why the validator is a function, not a class. For reactive forms, a plain function is enough. It is testable in isolation — call it with a mock control, assert the return value. The class-based approach is reserved for template-driven forms, where the validator must be a directive to be attached in the template .
Why
ValidatorFnvsValidator.ValidatorFnis the function signature used in reactive forms and in the factory functions that produce validators.Validatoris the interface implemented by directive classes in template-driven forms. Both ultimately produce the sameValidationErrors | nullresult, but they are wired differently .
Built-in validators and when to reach for custom
Angular ships a set of validators covering the common cases. They are available as static methods on Validators for reactive forms and as attributes for template-driven forms.
| Validator | Reactive | Template-Driven |
|---|---|---|
| Required | Validators.required | required |
| Minimum length | Validators.minLength(3) | minlength="3" |
| Maximum length | Validators.maxLength(10) | maxlength="10" |
| Pattern | Validators.pattern(/regex/) | pattern="..." |
Validators.email | email | |
| Minimum | Validators.min(0) | min="0" |
| Maximum | Validators.max(100) | max="100" |
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
age: [null, [Validators.required, Validators.min(18)]],
username: ['', [Validators.required, Validators.minLength(3)]],
});
When built-ins are not enough. The moment the rule depends on more than the control’s own value, a built-in is no longer applicable. “This email must not already exist in the database” requires an HTTP call — that is an async validator. “Password and confirmation must match” requires reading two controls — that is a cross-field validator on the parent group. “This username must not be in a forbidden list” is a custom synchronous validator .
Why custom validators are the norm, not the exception. Most real forms combine several built-ins with at least one custom rule. The custom rule is where the application’s domain logic lives: a product code format, a date range, a business constraint. The built-ins handle the generic cases so the custom validators can focus on what is specific to the application.
Synchronous custom validators for reactive forms
The reactive form approach is direct: write a function, pass it to the control’s validator array. No directive, no NG_VALIDATORS token. The function is the validator.
export function forbiddenNameValidator(forbiddenName: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = forbiddenName.test(control.value);
return forbidden ? { forbiddenName: { value: control.value } } : null;
};
}
This is a factory function. It takes the configuration — the forbidden pattern — and returns the actual validator function. The returned function is what Angular calls when it validates. The factory pattern is how validators receive parameters: forbiddenNameValidator(/admin/i) produces a validator that rejects “admin” .
Wiring it into a form:
this.userForm = this.fb.group({
username: ['', [
Validators.required,
Validators.minLength(3),
forbiddenNameValidator(/admin|root|superuser/i),
]],
});
The validator array is evaluated left to right, but all validators run. Validators.required checks for an empty value, minLength checks the length, and the custom validator checks the pattern. If any fails, the control is invalid, and errors contains the failing keys .
Displaying the error:
<input formControlName="username" />
<div *ngIf="userForm.get('username')?.errors?.['forbiddenName']">
This username is not allowed.
</div>
The errors?.['forbiddenName'] check matches the key returned by the validator. The optional chaining handles the null case when the control is valid .
Why the factory pattern is standard. The alternative — a single validator function that reads configuration from somewhere global — makes the validator harder to test and impossible to reuse with different parameters. The factory makes the configuration explicit at the call site and keeps the validator a pure function of its input.
Cross-field validation
Cross-field validation runs on the FormGroup, not on an individual FormControl. The validator receives the group, reads sibling controls with control.get('name'), and compares their values .
export const passwordMatchValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const password = control.get('password');
const confirmPassword = control.get('confirmPassword');
if (!password || !confirmPassword) {
return null;
}
return password.value === confirmPassword.value
? null
: { passwordMismatch: true };
};
The validator returns null when the fields match and an error object when they do not. The error is attached to the group, not to either control individually. That means form.errors?.['passwordMismatch'] is how the template checks it .
Attaching to the group:
this.signupForm = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });
The second argument to fb.group is the options object, and validators is where group-level validators go .
Displaying the group error:
<div *ngIf="signupForm.hasError('passwordMismatch') && signupForm.get('confirmPassword')?.touched">
Passwords do not match.
</div>
hasError('passwordMismatch') is the group-level equivalent of errors?.['key']. The second condition — checking that the confirm field is touched — prevents showing the error before the user has interacted .
Why the group is the right level. A FormControl validator only sees its own value. It cannot know about a sibling. The FormGroup is the common ancestor, and it has get access to all its children. Any rule that involves more than one field belongs on the group .
Why cross-field validators are the most common custom validator. Password confirmation, date range checks, “end time must be after start time,” “if you select ‘other,’ the explanation field is required” — all of these require seeing two or more controls. The pattern is always the same: run on the group, read the siblings, return an error on the group.
Custom validators for template-driven forms
Template-driven forms cannot pass a function directly to a control. The validator must be a directive, and the directive registers itself with the NG_VALIDATORS token .
@Directive({
selector: '[appForbiddenName]',
standalone: true,
providers: [
{ provide: NG_VALIDATORS, useExisting: ForbiddenNameDirective, multi: true },
],
})
export class ForbiddenNameDirective implements Validator {
@Input('appForbiddenName') forbiddenName = '';
validate(control: AbstractControl): ValidationErrors | null {
const forbidden = new RegExp(this.forbiddenName, 'i').test(control.value);
return forbidden ? { forbiddenName: { value: control.value } } : null;
}
}
The useExisting provider is critical. It registers the directive instance as the validator, which means the forbiddenName input binding is available when validate runs. Using useClass would create a new instance without the bound input .
Using the directive:
<input
name="username"
[(ngModel)]="username"
#usernameField="ngModel"
appForbiddenName="admin"
required
/>
<div *ngIf="usernameField.errors?.['forbiddenName']">
This username is not allowed.
</div>
The selector [appForbiddenName] matches the attribute. The value "admin" binds to the forbiddenName input, which the validate method uses to build the regex .
Why useExisting matters. useClass tells Angular to create a new instance of the directive class and use that as the validator. But the new instance has none of the input bindings — forbiddenName would be the default empty string. useExisting says “use the instance that already exists in the template, the one with the binding applied.” That is the instance that has the configuration .
Why cross-field validators go on the <form> tag. A cross-field validator needs the whole group. In template-driven forms, the NgForm directive is the group. Placing the validator directive on the <form> element gives it access to all controls .
<form #actorForm="ngForm" appUnambiguousRole>
The directive’s validate method receives the NgForm as its control argument, and control.get('name') works the same way it does on a reactive FormGroup .
Asynchronous validators
Async validators return a Promise or Observable that eventually resolves to ValidationErrors | null. They run after all synchronous validators pass, and the control enters a pending state while the async validation is in flight .
@Injectable({ providedIn: 'root' })
export class UsernameValidator implements AsyncValidator {
private readonly userService = inject(UserService);
validate(control: AbstractControl): Observable<ValidationErrors | null> {
return this.userService.checkUsernameExists(control.value).pipe(
map((exists) => (exists ? { usernameExists: true } : null)),
catchError(() => of(null)),
);
}
}
The validate method returns an Observable. The service call checks the server; map transforms the boolean into a ValidationErrors object or null; catchError returns null on a network error so a failed request does not block the form .
Wiring into a reactive form:
this.form = this.fb.group({
username: ['', [Validators.required], [this.usernameValidator]],
});
The third argument to the control configuration is the async validator array. The second is the synchronous validators. Both are needed; the async validator only runs if the sync validators pass .
Showing the pending state:
<input formControlName="username" />
<div *ngIf="form.get('username')?.pending">
Checking...
</div>
pending is true while the async validator is running. This is the state to show a spinner or a “checking availability” message .
Why async validation is gated behind sync validation. An async validator typically makes an HTTP request. Running it on every keystroke before checking whether the field is even non-empty wastes requests. Angular’s design — run sync first, async only if sync passes — avoids the waste. The pending state is what tells the UI that something is happening.
Why the observable must complete. Angular requires the observable returned by an async validator to be finite — it must complete. An infinite observable would leave the control in pending forever. Operators like first, take(1), or takeUntil convert infinite streams into finite ones .
Why
debounceTimeis common in async validators. Firing a server check on every keystroke floods the network.debounceTime(300)waits for the user to stop typing before sending the request. The user types “admin,” pauses, and one request is sent instead of five .
Complete Example Session
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators, AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// ============================================
// PART 1: SYNCHRONOUS CUSTOM VALIDATOR
// ============================================
export function forbiddenNameValidator(forbidden: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const isForbidden = forbidden.test(control.value);
return isForbidden ? { forbiddenName: { value: control.value } } : null;
};
}
// ============================================
// PART 2: CROSS-FIELD VALIDATOR
// ============================================
export const passwordMatchValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const password = control.get('password');
const confirm = control.get('confirmPassword');
if (!password || !confirm) return null;
return password.value === confirm.value
? null
: { passwordMismatch: true };
};
// ============================================
// PART 3: COMPONENT
// ============================================
@Component({
selector: 'app-signup',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
template: `
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
<label for="username">Username</label>
<input id="username" formControlName="username" />
<div *ngIf="signupForm.get('username')?.errors?.['forbiddenName']">
This username is not allowed.
</div>
<label for="password">Password</label>
<input id="password" type="password" formControlName="password" />
<div *ngIf="signupForm.get('password')?.errors?.['minlength']">
Password must be at least 8 characters.
</div>
<label for="confirmPassword">Confirm Password</label>
<input id="confirmPassword" type="password" formControlName="confirmPassword" />
<div *ngIf="signupForm.hasError('passwordMismatch') && signupForm.get('confirmPassword')?.touched">
Passwords do not match.
</div>
<button type="submit" [disabled]="signupForm.invalid">Sign Up</button>
</form>
`,
})
export class SignupComponent {
signupForm = this.fb.group({
username: ['', [
Validators.required,
Validators.minLength(3),
forbiddenNameValidator(/admin|root|superuser/i),
]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });
constructor(private fb: FormBuilder) {}
onSubmit() {
if (this.signupForm.valid) {
console.log(this.signupForm.value);
}
}
}
The form demonstrates three validators working together: the built-in required and minLength, the custom forbiddenNameValidator on the username control, and the cross-field passwordMatchValidator on the group. Each error surfaces through the same errors mechanism, and the template checks the specific error key it cares about.
Quick Reference
Validator Interfaces
| Interface | Used In | Signature |
|---|---|---|
ValidatorFn | Reactive forms, factories | (control: AbstractControl) => ValidationErrors | null |
Validator | Template-driven directives | validate(control: AbstractControl): ValidationErrors | null |
AsyncValidatorFn | Reactive async | (control: AbstractControl) => Promise<ValidationErrors | null> | Observable<...> |
AsyncValidator | Template-driven async | validate(control: AbstractControl): Promise<...> | Observable<...> |
Wiring Validators
| Form Type | Sync Validators | Async Validators |
|---|---|---|
| Reactive control | [validators] 2nd arg | [asyncValidators] 3rd arg |
| Reactive group | { validators: fn } options | { asyncValidators: fn } |
| Template-driven | Directive + NG_VALIDATORS | Directive + NG_ASYNC_VALIDATORS |
Cross-Field Validation
| Aspect | Rule |
|---|---|
| Where to attach | Parent FormGroup |
| How to read siblings | control.get('name') |
| Error location | On the group: form.errors |
| Template check | form.hasError('key') |
| When to show | After both fields touched |
Async Validator Rules
| Rule | Reason |
|---|---|
| Return finite observable | Infinite stream = pending forever |
| Gate behind sync validators | Avoid unnecessary HTTP calls |
Use debounceTime | Prevent request floods |
Check pending in template | Show spinner/feedback |
catchError returns null | Network failure should not block form |
Common Error Keys
| Key | Source |
|---|---|
required | Validators.required |
minlength | Validators.minLength |
email | Validators.email |
pattern | Validators.pattern |
forbiddenName | Custom validator |
passwordMismatch | Cross-field validator |
usernameExists | Async validator |
Best Practices
✅ Do This:
// Factory function for parameterized validators
export function forbiddenNameValidator(re: RegExp): ValidatorFn {
return (control) => re.test(control.value) ? { forbiddenName: true } : null;
} // ✅
// Cross-field validator on the group
this.form = this.fb.group({...}, { validators: passwordMatchValidator }); // ✅
// useExisting for template-driven directives
providers: [{ provide: NG_VALIDATORS, useExisting: MyValidator, multi: true }] // ✅
// Async validator with debounce and catchError
return this.service.check(value).pipe(
debounceTime(300),
map(exists => exists ? { taken: true } : null),
catchError(() => of(null)),
); // ✅
// Show errors only after touched
<div *ngIf="control.touched && control.errors?.['key']">...</div> // ✅
❌ Don’t Do This:
// Don't use useClass for template validators
providers: [{ provide: NG_VALIDATORS, useClass: MyValidator, multi: true }] // ⚠️
// Don't validate cross-field rules on individual controls
// The control cannot see its siblings // ⚠️
// Don't forget the observable must complete
return of(value).pipe(switchMap(() => this.service.check())) // ⚠️ may be infinite
// Don't show errors before user interaction
<div *ngIf="control.errors?.['required']">Required</div> // ⚠️
// Don't ignore the pending state for async validation
// Show feedback while checking // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
useClass for directive validators | New instance, inputs missing | Use useExisting |
| Cross-field on wrong control | Siblings invisible | Attach to parent FormGroup |
| Async observable never completes | Control stuck pending | Use take(1) or first() |
No debounceTime | HTTP flood on keystrokes | Add debounce |
| Errors shown before touched | Premature error display | Check touched || dirty |
ValidatorFn typed as FormGroup | Type mismatch | Use AbstractControl parameter |
Forgetting multi: true | Only one validator registered | Always set multi: true |
| Group validator signature wrong | Compile error | (control: AbstractControl) |
Real-World Examples
1. Forbidden username
forbiddenNameValidator(/admin|root/i)
2. Password confirmation
{ validators: passwordMatchValidator }
3. Async username availability
username: ['', [Validators.required], [this.usernameValidator]]
4. Date range on group
{ validators: dateRangeValidator }
5. Conditional required
requiredIfValidator(predicate)
6. Pattern validator
Validators.pattern(/^[0-9]{5}$/)
7. Range validator
rangeValidator(0, 10000)
8. Template-driven directive
<input appForbiddenName="admin" />
9. Async with debounce
debounceTime(500)
10. Pending spinner
<div *ngIf="control.pending">Checking...</div>
Visual: Validator Execution Order
┌──────────────────────────────────────────────────────┐
│ User types in control │
│ │ │
│ ▼ │
│ Value changes │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ SYNCHRONOUS VALIDATORS │ │
│ │ (all run, in order) │ │
│ │ required → minlength → pattern │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ├── Any fail? ──► Control INVALID, stop │
│ │ │
│ ▼ │
│ All sync pass │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ ASYNC VALIDATORS │ │
│ │ Control enters PENDING │ │
│ │ HTTP call fires │ │
│ └─────────────────────────────────────┘ │
│ │ │
│ ├── Resolve to error? ──► INVALID │
│ │ │
│ └── Resolve to null ──► VALID │
│ │
└──────────────────────────────────────────────────────┘
Visual: Custom Validator Factory
┌──────────────────────────────────────────────────────┐
│ forbiddenNameValidator(/admin/i) │
│ │ │
│ ▼ │
│ returns ValidatorFn │
│ │ │
│ └── (control: AbstractControl) => { │
│ const forbidden = regex.test(value); │
│ return forbidden ? { forbiddenName } : null;│
│ } │
│ │
│ Angular calls the returned function on every │
│ value change. │
│ │
└──────────────────────────────────────────────────────┘
Visual: Cross-Field Validation Location
┌──────────────────────────────────────────────────────┐
│ FormGroup (signupForm) │
│ ├── validators: passwordMatchValidator │
│ │ │ │
│ │ └── reads both children │
│ │ │
│ ├── FormControl (password) │
│ │ └── validators: required, minLength │
│ │ │
│ └── FormControl (confirmPassword) │
│ └── validators: required │
│ │
│ The match rule lives on the GROUP, not on │
│ either control. │
│ │
└──────────────────────────────────────────────────────┘
Visual: Async Validator States
┌──────────────────────────────────────────────────────┐
│ Value changes │
│ │ │
│ ▼ │
│ Sync validators pass │
│ │ │
│ ▼ │
│ status: PENDING ──► HTTP request in flight │
│ │ │
│ │ (spinner shown) │
│ │ │
│ ▼ │
│ Response arrives │
│ │ │
│ ├── Error returned ──► status: INVALID │
│ │ │
│ └── null returned ──► status: VALID │
│ │
└──────────────────────────────────────────────────────┘
Visual: useExisting vs useClass
┌──────────────────────────────────────────────────────┐
│ useExisting │
│ │
│ Directive instance in template │
│ ├── @Input bound to "admin" │
│ └── registered as validator │
│ │
│ Validator sees "admin". ✅ │
│ │
├──────────────────────────────────────────────────────┤
│ useClass │
│ │
│ NEW instance created │
│ ├── @Input NOT bound (default "") │
│ └── registered as validator │
│ │
│ Validator sees "". ❌ │
│ │
└──────────────────────────────────────────────────────┘
Summary
| Validator Type | Where It Runs | Wiring |
|---|---|---|
| Built-in sync | Control | Validators.x or attribute |
| Custom sync (reactive) | Control | Function in array |
| Custom sync (template) | Control | Directive + NG_VALIDATORS |
| Cross-field | Group | { validators: fn } or form tag |
| Async (reactive) | Control | Third array argument |
| Async (template) | Control | Directive + NG_ASYNC_VALIDATORS |
Key takeaways:
- A validator is a function returning
ValidationErrors | null— the error object’s keys become theerrorsproperty keys on the control - Built-in validators cover generic cases; custom validators handle domain logic and cross-field rules
- Factory functions parameterize validators —
forbiddenNameValidator(/admin/i)returns the function Angular calls - Cross-field validation runs on the parent
FormGroup, reads siblings withcontrol.get('name'), and returns the error on the group - Template-driven custom validators are directives registered with
NG_VALIDATORSanduseExisting— neveruseClass - Async validators run after sync validators pass, return a finite
PromiseorObservable, and put the control in apendingstate - Show errors only after
touched || dirty— premature validation messages are a poor user experience debounceTimein async validators prevents request floods on every keystroke- The error key is the contract — the validator returns
{ forbiddenName: ... }, and the template checkserrors?.['forbiddenName']
Remember: Angular’s validation system is uniform. Every validator — built-in, custom, cross-field, async — produces the same ValidationErrors | null result and lands in the same errors object. The differences are where the validator is attached (control vs group), how it is wired (function vs directive), and when it runs (sync vs async). Master those three variables, and the rest is application-specific rules.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!