Table of Contents
- Angular Forms Overview
- Built-in Validators: Limitations
- Types of Custom Validators
- Building Synchronous Custom Validators
- Building Asynchronous Custom Validators
- Displaying Validation Errors in Templates
- Testing Custom Validators
- Best Practices for Custom Validators
- Reference
1. Angular Forms Overview
Angular supports two approaches to form management:
- Reactive Forms: Explicitly define form controls in TypeScript, offering full control over validation, state, and reactivity. Ideal for complex forms.
- Template-Driven Forms: Declarative syntax in templates using
ngModel, with validation logic tied to template directives. Simpler for basic forms.
Both approaches rely on validators to enforce input rules. Validators are functions that check if a form control’s value is valid and return an error object if not.
2. Built-in Validators: Limitations
Angular’s @angular/forms package includes a set of built-in validators via the Validators class:
import { Validators } from '@angular/forms';
const control = new FormControl('', [
Validators.required,
Validators.email,
Validators.minLength(5)
]);
While useful, built-in validators are limited to generic cases. For example:
- They can’t validate against dynamic data (e.g., checking if a username exists in a database).
- They can’t enforce custom business rules (e.g., “password must contain a number and a special character”).
This is where custom validators shine.
3. Types of Custom Validators
Angular validators come in two flavors:
Synchronous Validators
- Run immediately when the form control’s value changes.
- Return
ValidationErrors | null(whereValidationErrorsis a key-value object describing the error).
Asynchronous Validators
- Run operations that take time (e.g., API calls to check username uniqueness).
- Return
Promise<ValidationErrors | null>orObservable<ValidationErrors | null>. - Angular waits for the async operation to complete before marking the control as valid/invalid.
4. Building Synchronous Custom Validators
Synchronous validators are pure functions that take an AbstractControl (the base class for FormControl, FormGroup, and FormArray) and return validation errors or null.
Example 1: Forbidden Name Validator
Let’s create a validator that rejects a specific name (e.g., “admin”) to prevent reserved usernames.
Step 1: Define the Validator Function
// forbidden-name.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenNameValidator(forbiddenName: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
// Check if control value exists and matches the forbidden name
const isForbidden = control.value?.toLowerCase() === forbiddenName.toLowerCase();
// Return error object if invalid; null if valid
return isForbidden ? { forbiddenName: { value: control.value } } : null;
};
}
ValidatorFn: A function that returns a validator (adheres to Angular’s validator interface).ValidationErrors: An object with keys (error names) and values (optional metadata, e.g., the invalid value).
Using Validators in Reactive Forms
To use the validator in a reactive form, add it to a FormControl, FormGroup, or FormArray:
// user-form.component.ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { forbiddenNameValidator } from './forbidden-name.validator';
@Component({
selector: 'app-user-form',
template: `...`
})
export class UserFormComponent {
userForm = new FormGroup({
username: new FormControl('', [
Validators.required, // Built-in validator
forbiddenNameValidator('admin') // Custom validator
])
});
// Getter for easy access to the username control
get username() {
return this.userForm.get('username');
}
}
Using Validators in Template-Driven Forms (Directives)
Template-driven forms use directives to attach validators. To make our forbiddenNameValidator work with ngModel, wrap it in a directive:
Step 1: Create a Validator Directive
// forbidden-name.directive.ts
import { Directive, Input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, Validator } from '@angular/forms';
import { forbiddenNameValidator } from './forbidden-name.validator';
@Directive({
selector: '[appForbiddenName]',
providers: [
{
provide: NG_VALIDATORS,
useExisting: ForbiddenNameDirective,
multi: true // Allow multiple validators
}
]
})
export class ForbiddenNameDirective implements Validator {
@Input('appForbiddenName') forbiddenName!: string;
validate(control: AbstractControl): ValidationErrors | null {
// Reuse the validator function
return this.forbiddenName ? forbiddenNameValidator(this.forbiddenName)(control) : null;
}
}
Step 2: Use the Directive in the Template
<!-- user-form.component.html -->
<form #userForm="ngForm">
<input
type="text"
name="username"
ngModel
appForbiddenName="admin" <!-- Pass forbidden name as input -->
required
>
</form>
5. Building Asynchronous Custom Validators
Async validators handle operations like API calls. They return a promise or observable that resolves to validation errors or null.
Example 2: Username Availability Check
Let’s create a validator that checks if a username is already taken by calling an API.
Step 1: Define the Async Validator Function
// username-availability.validator.ts
import { AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { delay, map } from 'rxjs/operators';
import { UserService } from './user.service'; // Assume this service has an API method
export function usernameAvailabilityValidator(userService: UserService): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const username = control.value;
if (!username) {
return of(null); // No value? Return valid immediately
}
// Call API to check availability (simulate delay with `delay`)
return userService.checkUsernameAvailability(username).pipe(
delay(1000), // Simulate network latency
map(isAvailable => {
// Return error if username is taken; null if available
return !isAvailable ? { usernameTaken: true } : null;
})
);
};
}
Step 2: Use the Async Validator in a Reactive Form
Async validators are passed as the third argument to FormControl (after sync validators):
// user-form.component.ts
import { FormControl } from '@angular/forms';
import { usernameAvailabilityValidator } from './username-availability.validator';
import { UserService } from './user.service';
@Component({ ... })
export class UserFormComponent {
constructor(private userService: UserService) {}
username = new FormControl('',
[Validators.required], // Sync validators
[usernameAvailabilityValidator(this.userService)] // Async validators
);
}
6. Displaying Validation Errors in Templates
To show validation errors to users, check the form control’s errors property in the template.
For Reactive Forms
<!-- user-form.component.html -->
<div *ngIf="username.invalid && (username.dirty || username.touched)">
<div *ngIf="username.errors?.required">Username is required.</div>
<div *ngIf="username.errors?.forbiddenName">
"{{ username.errors.forbiddenName.value }}" is a reserved name.
</div>
<div *ngIf="username.errors?.usernameTaken">
Username is already taken.
</div>
<div *ngIf="username.pending">Checking availability...</div> <!-- Async pending state -->
</div>
dirty: Control has been modified by the user.touched: Control has lost focus.pending: Async validator is still running (use to show loading states).
For Template-Driven Forms
Use a template reference variable (e.g., #username="ngModel") to access the control’s state:
<input
type="text"
name="username"
ngModel
#username="ngModel"
appForbiddenName="admin"
required
>
<div *ngIf="username.invalid && (username.dirty || username.touched)">
<div *ngIf="username.errors?.required">Username is required.</div>
<div *ngIf="username.errors?.forbiddenName">
"{{ username.errors.forbiddenName.value }}" is reserved.
</div>
</div>
7. Testing Custom Validators
Unit testing ensures your validators work as expected. Use Jasmine/Karma to test edge cases.
Example Test for forbiddenNameValidator
// forbidden-name.validator.spec.ts
import { AbstractControl } from '@angular/forms';
import { forbiddenNameValidator } from './forbidden-name.validator';
describe('forbiddenNameValidator', () => {
const validator = forbiddenNameValidator('admin');
it('should return null for a valid name ("user")', () => {
const control = { value: 'user' } as AbstractControl;
expect(validator(control)).toBeNull();
});
it('should return forbiddenName error for "admin"', () => {
const control = { value: 'admin' } as AbstractControl;
expect(validator(control)).toEqual({ forbiddenName: { value: 'admin' } });
});
it('should be case-insensitive ("Admin" is invalid)', () => {
const control = { value: 'Admin' } as AbstractControl;
expect(validator(control)).toEqual({ forbiddenName: { value: 'Admin' } });
});
});
8. Best Practices for Custom Validators
- Keep Validators Pure: Avoid side effects (e.g., API calls in sync validators). Async validators should be the only ones with side effects.
- Reuse Validators: Design validators to accept parameters (e.g.,
forbiddenNameValidator('admin')instead of hardcoding “admin”). - Compose Validators: Combine validators with
Validators.compose(for sync) orValidators.composeAsync(for async):const passwordValidators = Validators.compose([ Validators.required, Validators.minLength(8), passwordStrengthValidator() ]); - Handle Async Gracefully: Cancel pending API calls if the control value changes (use
switchMapin observables to avoid race conditions). - Provide Clear Error Messages: Include metadata in
ValidationErrors(e.g.,{ minAge: { required: 18, actual: 16 } }) to display context-rich errors.
9. Reference
- Angular Official Docs: Validators
- Angular Forms API
- Reactive Forms Guide
- Template-Driven Forms Guide
By mastering custom validators, you can enforce precise input rules tailored to your application’s needs. Whether sync or async, these tools empower you to build robust, user-friendly forms in Angular.