cyberangles guide

Building Custom Validators in Angular Forms

Angular provides a robust forms module that simplifies managing user inputs, validation, and state. While Angular’s built-in validators (e.g., `required`, `email`, `minLength`) handle common scenarios, real-world applications often require **custom validation logic** to enforce business rules, complex constraints, or domain-specific requirements. Examples include validating password strength, ensuring unique usernames, or checking date ranges. In this blog, we’ll explore how to build, test, and integrate custom validators in Angular forms. We’ll cover both synchronous and asynchronous validators, their usage in reactive and template-driven forms, and best practices to ensure reusability and maintainability.

Table of Contents

  1. Angular Forms Overview
  2. Built-in Validators: Limitations
  3. Types of Custom Validators
  4. Building Synchronous Custom Validators
  5. Building Asynchronous Custom Validators
  6. Displaying Validation Errors in Templates
  7. Testing Custom Validators
  8. Best Practices for Custom Validators
  9. 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 (where ValidationErrors is 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> or Observable<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

  1. Keep Validators Pure: Avoid side effects (e.g., API calls in sync validators). Async validators should be the only ones with side effects.
  2. Reuse Validators: Design validators to accept parameters (e.g., forbiddenNameValidator('admin') instead of hardcoding “admin”).
  3. Compose Validators: Combine validators with Validators.compose (for sync) or Validators.composeAsync (for async):
    const passwordValidators = Validators.compose([  
      Validators.required,  
      Validators.minLength(8),  
      passwordStrengthValidator()  
    ]);  
  4. Handle Async Gracefully: Cancel pending API calls if the control value changes (use switchMap in observables to avoid race conditions).
  5. Provide Clear Error Messages: Include metadata in ValidationErrors (e.g., { minAge: { required: 18, actual: 16 } }) to display context-rich errors.

9. Reference

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.