Custom validators extend Angular’s built-in validation with application-specific rules. A synchronous validator is a plain function that returns null (valid) or a ValidationErrors object (invalid) — no side effects, instant result. An async validator returns an Observable<ValidationErrors | null> — it can call the API to check uniqueness. Async validators run only after all sync validators pass, preventing unnecessary API calls for obviously invalid values.
Custom Sync and Async Validators
import { ValidatorFn, AsyncValidatorFn, AbstractControl,
ValidationErrors } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';
// ── Synchronous custom validators ─────────────────────────────────────────
// Slug format validator — only lowercase letters, numbers, and hyphens
export function slugFormatValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value as string;
if (!value) return null; // let required validator handle empty
const valid = /^[a-z0-9-]+$/.test(value);
return valid ? null : { slugFormat: { value } };
};
}
// No whitespace validator — rejects strings that are only whitespace
export function noWhitespaceValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value as string;
if (!value) return null;
return value.trim().length > 0 ? null : { whitespace: true };
};
}
// Validator factory — parameterised validator
export function forbiddenWordsValidator(words: string[]): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = (control.value as string)?.toLowerCase() ?? '';
const found = words.find(w => value.includes(w.toLowerCase()));
return found ? { forbiddenWord: { word: found } } : null;
};
}
// ── Async validator — checks slug uniqueness via API ─────────────────────
// Factory function: takes the PostsApiService, returns an AsyncValidatorFn
export function slugUniqueValidator(
api: PostsApiService,
currentId?: number // exclude current post's own slug in edit mode
): AsyncValidatorFn {
return (control: AbstractControl): Observable<ValidationErrors | null> => {
const slug = control.value as string;
if (!slug || !/^[a-z0-9-]+$/.test(slug)) return of(null); // sync check first
// Debounce: wait 400ms after last keystroke before calling API
return timer(400).pipe(
switchMap(() => api.checkSlugAvailable(slug, currentId)),
map(available => available ? null : { slugTaken: true }),
catchError(() => of(null)), // on API error, do not block the form
);
};
}
// ── Apply validators to a FormControl ─────────────────────────────────────
@Component({ standalone: true, imports: [ReactiveFormsModule], template: `
<form [formGroup]="form" novalidate>
<input formControlName="slug" placeholder="post-slug">
@if (form.controls.slug.pending) {
<span class="checking">⏳ Checking availability...</span>
}
@if (form.controls.slug.invalid && form.controls.slug.touched) {
@if (form.controls.slug.hasError('required')) { <span>Slug required.</span> }
@if (form.controls.slug.hasError('slugFormat')) { <span>Invalid slug format.</span> }
@if (form.controls.slug.hasError('slugTaken')) { <span>Slug is already taken.</span> }
}
</form>
` })
export class PostSlugComponent {
private fb = inject(FormBuilder);
private api = inject(PostsApiService);
form = this.fb.nonNullable.group({
slug: [
'',
[Validators.required, slugFormatValidator()], // sync validators (second arg)
[slugUniqueValidator(this.api)], // async validators (third arg)
],
});
}
slugFormatValidator() (contains uppercase or spaces), the slugUniqueValidator() API call is never made. This is the correct behaviour — no point checking if “Invalid Slug!” is unique when it will fail format validation anyway. Always put cheap synchronous validators before expensive async validators to avoid unnecessary API calls.timer(400) inside the async validator implements debouncing — it waits 400ms before making the API call. If the user types again within 400ms, the switchMap cancels the previous timer and starts a new one. Without debouncing, every keystroke triggers an API call. With timer(400) inside switchMap, only the final value (400ms after the last keystroke) triggers the check. This is the standard pattern for async form validation with API calls.control.pending state is true while async validators are running. Display a “checking…” indicator in this state so users know their input is being validated. Without this, the submit button is disabled (because the form is neither valid nor invalid — it is pending) and users may think something is broken. The pending state is exclusive from valid and invalid — always check it and provide appropriate UI feedback.Common Mistakes
Mistake 1 — No debounce on async validators (API call per keystroke)
❌ Wrong — async validator calls API immediately on every change; 10 keystrokes = 10 API calls.
✅ Correct — use timer(400).pipe(switchMap(...)) inside async validator to debounce.
Mistake 2 — Returning empty object {} for valid state (control always invalid)
❌ Wrong — return {} when valid; {} is truthy; Angular treats any non-null return as invalid.
✅ Correct — return null for valid; return { errorKey: true } for invalid.