The sun was barely climbing over Medellín’s Aburrá Valley when I hopped on a video call from a breezy veranda in Santo Domingo. Lucía, our newest hire in Bogotá, had built a slick sign-up flow—except users could enter the same password they used last year, bypassing security policy. “Angular’s built-in rules don’t cover our history check,” she sighed as rain hammered my balcony roof. A quick screen share, one custom validator, and a dash of async logic later, we blocked recycled passwords without freezing the UI. Frame times stayed smooth on her budget phone, security passed audit, and I still made it to desayuno with time to spare. That morning fix is the backbone for today’s deep dive into rolling your own form validators.
Why Custom Validation Deserves a Spot in Your Toolkit
Browser-native constraints handle basics like required
and pattern
. Angular’s synchronous validators catch most use cases—yet real products often demand checks such as:
- Cross-field parity (confirm email, match passwords).
- Domain rules (company-only addresses, banned words).
- Remote policies (username uniqueness, password history).
Shipping those conditions client-side means earlier feedback, smaller server logs, and accessible error messaging for screen readers. Done correctly, custom validators slot into Angular’s reactive-forms engine as first-class citizens—no hacks, no performance penalties.
Foundations: Validators in Plain English
A validator is a pure function. It receives an AbstractControl
, inspects its current value (or sibling controls), and returns either null
when all is well or an error object describing the failure. Angular merges these error maps so template directives such as formControl.errors?.passwordHistory
reveal precise messages.
Async validators share the same contract but return an Observable
or Promise
that resolves to the error map. Perfect for hitting APIs without blocking change detection.
Building a Synchronous Rule: Banning Reused Passwords
First, scaffold a new standalone file:
bashCopyEditng g class validators/password-history --type=validator --skip-tests
tsCopyEdit// validators/password-history.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function passwordHistoryValidator(
lastHashes: string[]
): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const raw = String(control.value || '');
const hash = sha256(raw); // placeholder for real hash
return lastHashes.includes(hash)
? { passwordHistory: true }
: null;
};
}
Hook it into a FormControl
:
tsCopyEditform = this.fb.group({
password: [
'',
[Validators.required, Validators.minLength(8), passwordHistoryValidator(this.prev)]
],
});
Angular runs our rule whenever the control value changes, in the same pass as built-ins.
Adding Async Validation: Username Availability
tsCopyEditexport function usernameTakenValidator(api: UserService): AsyncValidatorFn {
return (control: AbstractControl) =>
timer(400).pipe( // debounce keystrokes
switchMap(() => api.exists(control.value)),
map(exists => (exists ? { usernameTaken: true } : null)),
first()
);
}
Attach it:
tsCopyEditusername: [
'',
[Validators.required, Validators.pattern(/^[a-z\d]{4,}$/i)],
[usernameTakenValidator(this.api)]
],
Angular disables the submit button while the observable is unresolved—no extra code.
Remote-Work Insight Box
When our team spread across five time zones, we noticed async validators hammering staging. We wrapped every API call in a small NgRx effect that cached results for ten minutes. CDN edge locations near Panama and Brazil served hits instantly, bandwidth dropped, and our Peruvian QA colleague finally stopped seeing “rate limit” toast notifications at midnight.
Rendering Friendly Error Messages
htmlCopyEdit<input type="password" formControlName="password" />
<small *ngIf="form.get('password')?.errors?.passwordHistory">
Please pick a password you haven’t used before.
</small>
Screen readers announce the message because it appears after the input in DOM order; add aria-live="polite"
for extra safety.
Performance & Accessibility Checkpoints
- Change-Detection Cost: Custom validators run inside the same digest as other rules. Keep them pure and lightweight; heavy crypto hashing belongs in a Web Worker.
- Total Blocking Time: Debounce async requests; use
switchMap
to cancel stale calls when users keep typing. - Mobile First: Test on Chrome DevTools throttled to 4× CPU slowdown; ensure typing latency stays under 50 ms.
- ARIA: Hook errors into
<small role="alert">
for instantaneous screen-reader detail.
Common Pitfalls & Straightforward Remedies
Symptom | Likely Cause | Quick Fix |
---|---|---|
Infinite loop of validator runs | Function recreated in template | Define validator once in constructor , not inline |
Async rule never resolves | Observable missing complete | Finish with first() or take(1) |
Errors disappear on blur | Control marked touched too early | Trigger validation on submit or valueChanges only |
Server & client rules diverge | Two code paths | Share schema via Zod or run same validator in NestJS |
CLI Command Call-Out
CLI Command | Purpose |
---|---|
ng g class validators/age-range --type=validator | Skeleton for new rule |
ng test | Run spec suite; custom validators need unit tests |
ng profiler timeChanges | Benchmark validator impact |
Sketching the Architecture (SVG Idea)
A flow diagram where user input enters FormControl
➜ synchronous validators run ➜ async validator debounced ➜ API response merges errors ➜ template updates ➜ ARIA alert announces change. Side branch shows NgRx cache path to illustrate performance boost.
Bringing It All Together
Below is a compact signup component that ties the concepts:
tsCopyEdit@Component({
selector: 'signup-form',
templateUrl: './signup.html',
standalone: true,
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SignupFormComponent {
lastHashes = ['ab12...', 'cd34...']; // fetched on init
form = this.fb.group({
username: [
'',
[Validators.required, Validators.minLength(4)],
[usernameTakenValidator(this.api)]
],
password: [
'',
[Validators.required, Validators.minLength(8), passwordHistoryValidator(this.lastHashes)]
],
confirm: [''],
}, { validators: match('password', 'confirm') });
constructor(private fb: FormBuilder, private api: UserService) {}
submit() {
if (this.form.valid) this.api.register(this.form.value).subscribe();
}
}
Halo of OnPush, custom sync rule, async availability check, and a cross-field matcher. Users get instant feedback; bandwidth stays lean.
Wrap-Up
Crafting your own validators turns Angular’s forms from generic to bespoke without surrendering performance or accessibility. Keep functions pure, debounce async logic, and cache wherever remote work leads you—from Colombian rooftops to Mexican coworks. Your users will feel the polish, and compliance auditors will smile.
Have a tricky validation scenario? Drop it below; I’ll reply between flights and late-night arepa sessions.