AngularJSCustom Validators

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.

TS
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": true
Validators with Parameters (Factory Pattern)

When a validator needs configuration, use a factory function that returns the validator.

TS
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

TS
// 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.

TS
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 }
);

HTML
<!-- 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.

TS
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(),
      );
    };
  }
}

TS
// 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',
    }),
  });
}
Note
Async validators only run after all synchronous validators pass. This prevents unnecessary API calls when the field is empty or obviously invalid.
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.

TS
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;
  }
}

HTML
<!-- 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

TS
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

TS
// 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

Warning
Never throw exceptions inside a validator. If something goes wrong (e.g. a null control value), return null (valid) or a meaningful error object. An unhandled exception inside a validator will crash the change detection cycle.
Testing Custom Validators

TS
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();
  });
});
Tip
Custom validators are plain functions — test them without Angular's TestBed by simply creating a 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.