| |

Angular 32 🅰️ Form Validation and Custom Validators

Angular provides a validation system built on the AbstractControl class, which FormControl, FormGroup, and FormArray all extend . Every validator is a function that receives a control and returns either null (valid) or a ValidationErrors object (invalid) . Built-in validators handle common cases — required, minLength, email, pattern — but real applications almost always need custom rules: passwords must match, usernames cannot be taken, dates must be in a range, or a value must satisfy a business constraint. This chapter covers the full validation system: how validators are structured, how to build synchronous custom validators, how to wire them into both reactive and template-driven forms, how to handle cross-field validation, and how to implement asynchronous validators that check with a server. It builds directly on the form foundations from Angular 31, and the goal is a working command of Angular’s validator machinery.

Key point: A validator is a function (control: AbstractControl) => ValidationErrors | null. The error object’s keys become entries in the control’s errors property — { forbiddenName: { value: "admin" } } is read as control.errors?.['forbiddenName'] . Custom validators are normal functions for reactive forms and directive-wrapped functions for template-driven forms. Cross-field validation runs on the parent FormGroup and reads sibling controls with control.get('name') . Async validators return a Promise or Observable that resolves to errors or null, and the control enters a pending state while waiting .


The validator contract

Every validator in Angular — built-in or custom — satisfies the same interface. It receives an AbstractControl and returns either null or a ValidationErrors object. The AbstractControl is the base class; a FormControl, FormGroup, or FormArray can all be passed to a validator, and the validator can inspect whichever properties it needs .

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

export const noWhitespaceValidator: ValidatorFn = (
  control: AbstractControl,
): ValidationErrors | null => {
  const isWhitespace = (control.value || '').trim().length === 0;
  return isWhitespace ? { whitespace: true } : null;
};

The error key — whitespace here — is what the template checks. control.errors?.['whitespace'] is truthy when the validator failed. The value can carry details: { minlength: { requiredLength: 3, actualLength: 1 } } is how Angular’s built-in minLength reports the constraint .

Why the return type is ValidationErrors | null. Returning null means “this validator has nothing to say — the control is valid as far as I am concerned.” Returning an object means “this control is invalid, and here is why.” Multiple validators on the same control each return independently, and Angular merges the results into a single errors object. If any validator returns a non-null error, the control is invalid.

Why the validator is a function, not a class. For reactive forms, a plain function is enough. It is testable in isolation — call it with a mock control, assert the return value. The class-based approach is reserved for template-driven forms, where the validator must be a directive to be attached in the template .

Why ValidatorFn vs Validator. ValidatorFn is the function signature used in reactive forms and in the factory functions that produce validators. Validator is the interface implemented by directive classes in template-driven forms. Both ultimately produce the same ValidationErrors | null result, but they are wired differently .


Built-in validators and when to reach for custom

Angular ships a set of validators covering the common cases. They are available as static methods on Validators for reactive forms and as attributes for template-driven forms.

ValidatorReactiveTemplate-Driven
RequiredValidators.requiredrequired
Minimum lengthValidators.minLength(3)minlength="3"
Maximum lengthValidators.maxLength(10)maxlength="10"
PatternValidators.pattern(/regex/)pattern="..."
EmailValidators.emailemail
MinimumValidators.min(0)min="0"
MaximumValidators.max(100)max="100"
this.form = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
  age: [null, [Validators.required, Validators.min(18)]],
  username: ['', [Validators.required, Validators.minLength(3)]],
});

When built-ins are not enough. The moment the rule depends on more than the control’s own value, a built-in is no longer applicable. “This email must not already exist in the database” requires an HTTP call — that is an async validator. “Password and confirmation must match” requires reading two controls — that is a cross-field validator on the parent group. “This username must not be in a forbidden list” is a custom synchronous validator .

Why custom validators are the norm, not the exception. Most real forms combine several built-ins with at least one custom rule. The custom rule is where the application’s domain logic lives: a product code format, a date range, a business constraint. The built-ins handle the generic cases so the custom validators can focus on what is specific to the application.


Synchronous custom validators for reactive forms

The reactive form approach is direct: write a function, pass it to the control’s validator array. No directive, no NG_VALIDATORS token. The function is the validator.

export function forbiddenNameValidator(forbiddenName: RegExp): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const forbidden = forbiddenName.test(control.value);
    return forbidden ? { forbiddenName: { value: control.value } } : null;
  };
}

This is a factory function. It takes the configuration — the forbidden pattern — and returns the actual validator function. The returned function is what Angular calls when it validates. The factory pattern is how validators receive parameters: forbiddenNameValidator(/admin/i) produces a validator that rejects “admin” .

Wiring it into a form:

this.userForm = this.fb.group({
  username: ['', [
    Validators.required,
    Validators.minLength(3),
    forbiddenNameValidator(/admin|root|superuser/i),
  ]],
});

The validator array is evaluated left to right, but all validators run. Validators.required checks for an empty value, minLength checks the length, and the custom validator checks the pattern. If any fails, the control is invalid, and errors contains the failing keys .

Displaying the error:

<input formControlName="username" />
<div *ngIf="userForm.get('username')?.errors?.['forbiddenName']">
  This username is not allowed.
</div>

The errors?.['forbiddenName'] check matches the key returned by the validator. The optional chaining handles the null case when the control is valid .

Why the factory pattern is standard. The alternative — a single validator function that reads configuration from somewhere global — makes the validator harder to test and impossible to reuse with different parameters. The factory makes the configuration explicit at the call site and keeps the validator a pure function of its input.


Cross-field validation

Cross-field validation runs on the FormGroup, not on an individual FormControl. The validator receives the group, reads sibling controls with control.get('name'), and compares their values .

export const passwordMatchValidator: ValidatorFn = (
  control: AbstractControl,
): ValidationErrors | null => {
  const password = control.get('password');
  const confirmPassword = control.get('confirmPassword');

  if (!password || !confirmPassword) {
    return null;
  }

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

The validator returns null when the fields match and an error object when they do not. The error is attached to the group, not to either control individually. That means form.errors?.['passwordMismatch'] is how the template checks it .

Attaching to the group:

this.signupForm = this.fb.group({
  password: ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });

The second argument to fb.group is the options object, and validators is where group-level validators go .

Displaying the group error:

<div *ngIf="signupForm.hasError('passwordMismatch') && signupForm.get('confirmPassword')?.touched">
  Passwords do not match.
</div>

hasError('passwordMismatch') is the group-level equivalent of errors?.['key']. The second condition — checking that the confirm field is touched — prevents showing the error before the user has interacted .

Why the group is the right level. A FormControl validator only sees its own value. It cannot know about a sibling. The FormGroup is the common ancestor, and it has get access to all its children. Any rule that involves more than one field belongs on the group .

Why cross-field validators are the most common custom validator. Password confirmation, date range checks, “end time must be after start time,” “if you select ‘other,’ the explanation field is required” — all of these require seeing two or more controls. The pattern is always the same: run on the group, read the siblings, return an error on the group.


Custom validators for template-driven forms

Template-driven forms cannot pass a function directly to a control. The validator must be a directive, and the directive registers itself with the NG_VALIDATORS token .

@Directive({
  selector: '[appForbiddenName]',
  standalone: true,
  providers: [
    { provide: NG_VALIDATORS, useExisting: ForbiddenNameDirective, multi: true },
  ],
})
export class ForbiddenNameDirective implements Validator {
  @Input('appForbiddenName') forbiddenName = '';

  validate(control: AbstractControl): ValidationErrors | null {
    const forbidden = new RegExp(this.forbiddenName, 'i').test(control.value);
    return forbidden ? { forbiddenName: { value: control.value } } : null;
  }
}

The useExisting provider is critical. It registers the directive instance as the validator, which means the forbiddenName input binding is available when validate runs. Using useClass would create a new instance without the bound input .

Using the directive:

<input
  name="username"
  [(ngModel)]="username"
  #usernameField="ngModel"
  appForbiddenName="admin"
  required
/>
<div *ngIf="usernameField.errors?.['forbiddenName']">
  This username is not allowed.
</div>

The selector [appForbiddenName] matches the attribute. The value "admin" binds to the forbiddenName input, which the validate method uses to build the regex .

Why useExisting matters. useClass tells Angular to create a new instance of the directive class and use that as the validator. But the new instance has none of the input bindings — forbiddenName would be the default empty string. useExisting says “use the instance that already exists in the template, the one with the binding applied.” That is the instance that has the configuration .

Why cross-field validators go on the <form> tag. A cross-field validator needs the whole group. In template-driven forms, the NgForm directive is the group. Placing the validator directive on the <form> element gives it access to all controls .

<form #actorForm="ngForm" appUnambiguousRole>

The directive’s validate method receives the NgForm as its control argument, and control.get('name') works the same way it does on a reactive FormGroup .


Asynchronous validators

Async validators return a Promise or Observable that eventually resolves to ValidationErrors | null. They run after all synchronous validators pass, and the control enters a pending state while the async validation is in flight .

@Injectable({ providedIn: 'root' })
export class UsernameValidator implements AsyncValidator {
  private readonly userService = inject(UserService);

  validate(control: AbstractControl): Observable<ValidationErrors | null> {
    return this.userService.checkUsernameExists(control.value).pipe(
      map((exists) => (exists ? { usernameExists: true } : null)),
      catchError(() => of(null)),
    );
  }
}

The validate method returns an Observable. The service call checks the server; map transforms the boolean into a ValidationErrors object or null; catchError returns null on a network error so a failed request does not block the form .

Wiring into a reactive form:

this.form = this.fb.group({
  username: ['', [Validators.required], [this.usernameValidator]],
});

The third argument to the control configuration is the async validator array. The second is the synchronous validators. Both are needed; the async validator only runs if the sync validators pass .

Showing the pending state:

<input formControlName="username" />
<div *ngIf="form.get('username')?.pending">
  Checking...
</div>

pending is true while the async validator is running. This is the state to show a spinner or a “checking availability” message .

Why async validation is gated behind sync validation. An async validator typically makes an HTTP request. Running it on every keystroke before checking whether the field is even non-empty wastes requests. Angular’s design — run sync first, async only if sync passes — avoids the waste. The pending state is what tells the UI that something is happening.

Why the observable must complete. Angular requires the observable returned by an async validator to be finite — it must complete. An infinite observable would leave the control in pending forever. Operators like first, take(1), or takeUntil convert infinite streams into finite ones .

Why debounceTime is common in async validators. Firing a server check on every keystroke floods the network. debounceTime(300) waits for the user to stop typing before sending the request. The user types “admin,” pauses, and one request is sent instead of five .


Complete Example Session

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators, AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// ============================================
// PART 1: SYNCHRONOUS CUSTOM VALIDATOR
// ============================================

export function forbiddenNameValidator(forbidden: RegExp): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const isForbidden = forbidden.test(control.value);
    return isForbidden ? { forbiddenName: { value: control.value } } : null;
  };
}

// ============================================
// PART 2: CROSS-FIELD VALIDATOR
// ============================================

export const passwordMatchValidator: ValidatorFn = (
  control: AbstractControl,
): ValidationErrors | null => {
  const password = control.get('password');
  const confirm = control.get('confirmPassword');

  if (!password || !confirm) return null;

  return password.value === confirm.value
    ? null
    : { passwordMismatch: true };
};

// ============================================
// PART 3: COMPONENT
// ============================================

@Component({
  selector: 'app-signup',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
      <label for="username">Username</label>
      <input id="username" formControlName="username" />
      <div *ngIf="signupForm.get('username')?.errors?.['forbiddenName']">
        This username is not allowed.
      </div>

      <label for="password">Password</label>
      <input id="password" type="password" formControlName="password" />
      <div *ngIf="signupForm.get('password')?.errors?.['minlength']">
        Password must be at least 8 characters.
      </div>

      <label for="confirmPassword">Confirm Password</label>
      <input id="confirmPassword" type="password" formControlName="confirmPassword" />
      <div *ngIf="signupForm.hasError('passwordMismatch') && signupForm.get('confirmPassword')?.touched">
        Passwords do not match.
      </div>

      <button type="submit" [disabled]="signupForm.invalid">Sign Up</button>
    </form>
  `,
})
export class SignupComponent {
  signupForm = this.fb.group({
    username: ['', [
      Validators.required,
      Validators.minLength(3),
      forbiddenNameValidator(/admin|root|superuser/i),
    ]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
  }, { validators: passwordMatchValidator });

  constructor(private fb: FormBuilder) {}

  onSubmit() {
    if (this.signupForm.valid) {
      console.log(this.signupForm.value);
    }
  }
}

The form demonstrates three validators working together: the built-in required and minLength, the custom forbiddenNameValidator on the username control, and the cross-field passwordMatchValidator on the group. Each error surfaces through the same errors mechanism, and the template checks the specific error key it cares about.


Quick Reference

Validator Interfaces

InterfaceUsed InSignature
ValidatorFnReactive forms, factories(control: AbstractControl) => ValidationErrors | null
ValidatorTemplate-driven directivesvalidate(control: AbstractControl): ValidationErrors | null
AsyncValidatorFnReactive async(control: AbstractControl) => Promise<ValidationErrors | null> | Observable<...>
AsyncValidatorTemplate-driven asyncvalidate(control: AbstractControl): Promise<...> | Observable<...>

Wiring Validators

Form TypeSync ValidatorsAsync Validators
Reactive control[validators] 2nd arg[asyncValidators] 3rd arg
Reactive group{ validators: fn } options{ asyncValidators: fn }
Template-drivenDirective + NG_VALIDATORSDirective + NG_ASYNC_VALIDATORS

Cross-Field Validation

AspectRule
Where to attachParent FormGroup
How to read siblingscontrol.get('name')
Error locationOn the group: form.errors
Template checkform.hasError('key')
When to showAfter both fields touched

Async Validator Rules

RuleReason
Return finite observableInfinite stream = pending forever
Gate behind sync validatorsAvoid unnecessary HTTP calls
Use debounceTimePrevent request floods
Check pending in templateShow spinner/feedback
catchError returns nullNetwork failure should not block form

Common Error Keys

KeySource
requiredValidators.required
minlengthValidators.minLength
emailValidators.email
patternValidators.pattern
forbiddenNameCustom validator
passwordMismatchCross-field validator
usernameExistsAsync validator

Best Practices

Do This:

// Factory function for parameterized validators
export function forbiddenNameValidator(re: RegExp): ValidatorFn {
  return (control) => re.test(control.value) ? { forbiddenName: true } : null;
}                                                              // ✅

// Cross-field validator on the group
this.form = this.fb.group({...}, { validators: passwordMatchValidator }); // ✅

// useExisting for template-driven directives
providers: [{ provide: NG_VALIDATORS, useExisting: MyValidator, multi: true }] // ✅

// Async validator with debounce and catchError
return this.service.check(value).pipe(
  debounceTime(300),
  map(exists => exists ? { taken: true } : null),
  catchError(() => of(null)),
);                                                             // ✅

// Show errors only after touched
<div *ngIf="control.touched && control.errors?.['key']">...</div> // ✅

Don’t Do This:

// Don't use useClass for template validators
providers: [{ provide: NG_VALIDATORS, useClass: MyValidator, multi: true }] // ⚠️

// Don't validate cross-field rules on individual controls
// The control cannot see its siblings                            // ⚠️

// Don't forget the observable must complete
return of(value).pipe(switchMap(() => this.service.check()))    // ⚠️ may be infinite

// Don't show errors before user interaction
<div *ngIf="control.errors?.['required']">Required</div>        // ⚠️

// Don't ignore the pending state for async validation
// Show feedback while checking                                   // ⚠️

Common Pitfalls

PitfallProblemSolution
useClass for directive validatorsNew instance, inputs missingUse useExisting
Cross-field on wrong controlSiblings invisibleAttach to parent FormGroup
Async observable never completesControl stuck pendingUse take(1) or first()
No debounceTimeHTTP flood on keystrokesAdd debounce
Errors shown before touchedPremature error displayCheck touched || dirty
ValidatorFn typed as FormGroupType mismatchUse AbstractControl parameter
Forgetting multi: trueOnly one validator registeredAlways set multi: true
Group validator signature wrongCompile error(control: AbstractControl)

Real-World Examples

1. Forbidden username

forbiddenNameValidator(/admin|root/i)

2. Password confirmation

{ validators: passwordMatchValidator }

3. Async username availability

username: ['', [Validators.required], [this.usernameValidator]]

4. Date range on group

{ validators: dateRangeValidator }

5. Conditional required

requiredIfValidator(predicate)

6. Pattern validator

Validators.pattern(/^[0-9]{5}$/)

7. Range validator

rangeValidator(0, 10000)

8. Template-driven directive

<input appForbiddenName="admin" />

9. Async with debounce

debounceTime(500)

10. Pending spinner

<div *ngIf="control.pending">Checking...</div>

Visual: Validator Execution Order

┌──────────────────────────────────────────────────────┐
│  User types in control                               │
│       │                                              │
│       ▼                                              │
│  Value changes                                       │
│       │                                              │
│       ▼                                              │
│  ┌─────────────────────────────────────┐             │
│  │  SYNCHRONOUS VALIDATORS             │             │
│  │  (all run, in order)                │             │
│  │  required → minlength → pattern     │             │
│  └─────────────────────────────────────┘             │
│       │                                              │
│       ├── Any fail? ──► Control INVALID, stop        │
│       │                                              │
│       ▼                                              │
│  All sync pass                                       │
│       │                                              │
│       ▼                                              │
│  ┌─────────────────────────────────────┐             │
│  │  ASYNC VALIDATORS                   │             │
│  │  Control enters PENDING             │             │
│  │  HTTP call fires                    │             │
│  └─────────────────────────────────────┘             │
│       │                                              │
│       ├── Resolve to error? ──► INVALID              │
│       │                                              │
│       └── Resolve to null ──► VALID                  │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: Custom Validator Factory

┌──────────────────────────────────────────────────────┐
│  forbiddenNameValidator(/admin/i)                    │
│       │                                              │
│       ▼                                              │
│  returns ValidatorFn                                 │
│       │                                              │
│       └── (control: AbstractControl) => {            │
│              const forbidden = regex.test(value);    │
│              return forbidden ? { forbiddenName } : null;│
│            }                                         │
│                                                      │
│  Angular calls the returned function on every        │
│  value change.                                       │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: Cross-Field Validation Location

┌──────────────────────────────────────────────────────┐
│  FormGroup (signupForm)                              │
│  ├── validators: passwordMatchValidator              │
│  │       │                                           │
│  │       └── reads both children                     │
│  │                                                   │
│  ├── FormControl (password)                          │
│  │       └── validators: required, minLength         │
│  │                                                   │
│  └── FormControl (confirmPassword)                   │
│          └── validators: required                    │
│                                                      │
│  The match rule lives on the GROUP, not on           │
│  either control.                                     │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: Async Validator States

┌──────────────────────────────────────────────────────┐
│  Value changes                                       │
│       │                                              │
│       ▼                                              │
│  Sync validators pass                                │
│       │                                              │
│       ▼                                              │
│  status: PENDING ──► HTTP request in flight          │
│       │                                              │
│       │  (spinner shown)                             │
│       │                                              │
│       ▼                                              │
│  Response arrives                                    │
│       │                                              │
│       ├── Error returned ──► status: INVALID         │
│       │                                              │
│       └── null returned ──► status: VALID            │
│                                                      │
└──────────────────────────────────────────────────────┘

Visual: useExisting vs useClass

┌──────────────────────────────────────────────────────┐
│  useExisting                                         │
│                                                      │
│  Directive instance in template                      │
│    ├── @Input bound to "admin"                       │
│    └── registered as validator                       │
│                                                      │
│  Validator sees "admin". ✅                          │
│                                                      │
├──────────────────────────────────────────────────────┤
│  useClass                                            │
│                                                      │
│  NEW instance created                                │
│    ├── @Input NOT bound (default "")                 │
│    └── registered as validator                       │
│                                                      │
│  Validator sees "". ❌                               │
│                                                      │
└──────────────────────────────────────────────────────┘

Summary

Validator TypeWhere It RunsWiring
Built-in syncControlValidators.x or attribute
Custom sync (reactive)ControlFunction in array
Custom sync (template)ControlDirective + NG_VALIDATORS
Cross-fieldGroup{ validators: fn } or form tag
Async (reactive)ControlThird array argument
Async (template)ControlDirective + NG_ASYNC_VALIDATORS

Key takeaways:

  • A validator is a function returning ValidationErrors | null — the error object’s keys become the errors property keys on the control
  • Built-in validators cover generic cases; custom validators handle domain logic and cross-field rules
  • Factory functions parameterize validatorsforbiddenNameValidator(/admin/i) returns the function Angular calls
  • Cross-field validation runs on the parent FormGroup, reads siblings with control.get('name'), and returns the error on the group
  • Template-driven custom validators are directives registered with NG_VALIDATORS and useExisting — never useClass
  • Async validators run after sync validators pass, return a finite Promise or Observable, and put the control in a pending state
  • Show errors only after touched || dirty — premature validation messages are a poor user experience
  • debounceTime in async validators prevents request floods on every keystroke
  • The error key is the contract — the validator returns { forbiddenName: ... }, and the template checks errors?.['forbiddenName']

Remember: Angular’s validation system is uniform. Every validator — built-in, custom, cross-field, async — produces the same ValidationErrors | null result and lands in the same errors object. The differences are where the validator is attached (control vs group), how it is wired (function vs directive), and when it runs (sync vs async). Master those three variables, and the rest is application-specific rules.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!