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.
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
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.
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 = truewhile async validator is running
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.
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
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login