Chapter 8.5☕ 15 min read

Custom Validators — Sync & Async

Built-in validators cover common cases. Custom validators let you write any validation rule — sync (instant), async (API), cross-field, and reusable factories.

01Why Custom Validators

Built-in validators are great — but they can't cover every possible validation rule.

When you need custom validators:

  • Password strength check (must have uppercase, number, special char)
  • Username already taken (async — check with server)
  • Future date only (can't pick past dates)
  • Indian phone number (10 digits, starts with 6-9)
  • Credit card number (Luhn algorithm)
  • Cross-field validation (password match, date range)

"Custom validator = apna test — jo built-in mein nahi milta."

There are three types of custom validators:

  • Sync — instant check, returns result immediately
  • Async — server call needed, returns Observable
  • Cross-field — operates on FormGroup, compares multiple fields
02Sync Custom Validator

A sync validator is a function that takes an AbstractControl and returns ValidationErrors | null.

import { AbstractControl, ValidationErrors } from '@angular/forms';

// Simple validator — reject if value contains 'hyderabad'
export function noHyderabad(control: AbstractControl): ValidationErrors | null {
  const value = control.value?.toString().toLowerCase();
  
  if (value && value.includes('hyderabad')) {
    return { noHyderabad: true }; // Invalid — return error object
  }
  
  return null; // Valid
}

"Sync = instant check — data dekho, batado theek hai ya nahi."

Usage:

name: ['', [Validators.required, noHyderabad]]

// With FormBuilder
name: ['', [Validators.required, noHyderabad]]

The function is used directly as a validator — no need to wrap it in anything. Just pass the function reference.

03Async Custom Validator

An async validator returns Observable<ValidationErrors | null> instead of a direct result. It's used for server-side checks.

import { AbstractControl, ValidationErrors } from '@angular/forms';
import { Observable, of, map, catchError } from 'rxjs';
import { AuthService } from './auth.service';

export function usernameExists(authService: AuthService) {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    if (!control.value) {
      return of(null); // Don't check empty values
    }

    return authService.checkUsername(control.value).pipe(
      map(exists => exists ? { usernameExists: true } : null),
      catchError(() => of(null)) // API error = don't block user
    );
  };
}

"Async = phone call check — server se poocho, phir batado."

Usage — third position in array:

this.fb.group({
  username: ['', 
    [Validators.required, Validators.minLength(3)],  // Sync validators
    [usernameExists(this.authService)]  // Async validators — separate array!
  ],
});

Key rules for async validators:

  • MUST return Observable (not Promise, though Angular accepts both)
  • Place in the THIRD position in the array
  • Always catchError() — if API fails, return of(null) so the form doesn't stay in PENDING state forever
  • Angular shows control.pending = true while async validator is running
04Cross-Field Validator (FormGroup Level)

Cross-field validators operate on the FormGroup level — they can access multiple controls at once.

import { AbstractControl, ValidationErrors } from '@angular/forms';

export function passwordMatch(control: AbstractControl): ValidationErrors | null {
  const password = control.get('password')?.value;
  const confirmPassword = control.get('confirmPassword')?.value;

  // Don't validate if either field is empty — let individual validators handle that
  if (!password || !confirmPassword) {
    return null;
  }

  return password === confirmPassword ? null : { passwordMismatch: true };
}

"Cross-field = do fields compare karo — password match karta hai ya nahi."

Usage — on FormGroup, not individual control:

this.fb.group({
  password: ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['', Validators.required],
}, { 
  validators: passwordMatch  // ← Group level!
});

Showing the error:

@if (form.errors?.['passwordMismatch'] && form.get('confirmPassword')?.touched) {
  <small class="error">Passwords don't match bhai</small>
}

Note: group-level errors are on form.errors, not on individual control errors.

05Reusable Validator with Factory

A factory creates configurable validators — same logic, different parameters.

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Factory function — takes parameters, returns validator function
export function forbiddenName(forbidden: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value?.toString().toLowerCase();
    
    if (value && value.includes(forbidden.toLowerCase())) {
      return { 
        forbidden: { 
          value: forbidden, 
          actual: control.value 
        } 
      };
    }
    
    return null;
  };
}

"Factory = customized test — jo naam bolo, wo check hoga."

Usage — call the factory with parameters:

// Create different validators from the same factory
name: ['', [forbiddenName('admin')]]
username: ['', [forbiddenName('root')]]
title: ['', [forbiddenName('untitled')]]

// Each returns a different validator function with its own forbidden value

This pattern is essential for DRY validation code. Instead of writing separate validators for "no admin", "no root", "no untitled" — write one factory and use it with different parameters.

Key Takeaways

  • ✅ Sync validator: (control) => ValidationErrors | null — instant check, returns immediately
  • ✅ Async validator: (control) => Observable — server call, third array position
  • ✅ Always catchError(() => of(null)) in async validators — prevents form staying in PENDING state
  • ✅ Cross-field validator: applied on FormGroup as { validators: fn }, errors on form.errors
  • ✅ Factory pattern: function that takes params, returns validator function — DRY and reusable
Course Search
Search across all chapters & stages
📖

Search the course

Type any topic — branching, stash, rebase, hooks — and jump straight to that chapter.

merge branchesgit stashundo commitrebase