Angular Advanced Form Validation: Custom, Async, Cross-Field and setValidators

Angular Tutorial: Beginner to Advanced · 8. Forms and validation · 8 min read · Updated

Built-in validators (required, email, minLength) cover simple rules. Real forms also need your own: "the username must be free", "the end date must be after the start date", "the discount code must start with a given prefix". This lesson covers all of them.

What you will learn

  • Custom validators, with and without parameters
  • Using a service inside a validator
  • Async validators and the PENDING state
  • Cross-field validators on a group
  • setValidators() and custom validators for template-driven forms

What a validator is

A synchronous validator is a function that receives a control and returns null when the value is valid, or an error object when it is not:

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

export const noSpaces: ValidatorFn = (control: AbstractControl): ValidationErrors | null =>
  /\s/.test(control.value ?? '') ? { noSpaces: true } : null;

The key noSpaces becomes the error name, shown in control.errors?.['noSpaces'] and checked with control.hasError('noSpaces').

Validators with parameters

To pass parameters, write a function that returns a validator:

// src/app/forms4/validators.ts
import { AbstractControl, AsyncValidatorFn, ValidationErrors, ValidatorFn } from '@angular/forms';
import { Observable, delay, map, of } from 'rxjs';

// Simple rule: no spaces
export const noSpaces: ValidatorFn = (control) =>
  /\s/.test(control.value ?? '') ? { noSpaces: true } : null;

// With a parameter: value must start with a prefix
export function startsWith(prefix: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value: string = control.value ?? '';
    if (value === '') {
      return null; // let `required` deal with empty values
    }
    return value.startsWith(prefix) ? null : { startsWith: { required: prefix, actual: value } };
  };
}

// Cross-field: runs on the GROUP and compares two controls
export const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const password = group.get('password')?.value;
  const confirm = group.get('confirm')?.value;
  return password && confirm && password !== confirm ? { mismatch: true } : null;
};

// Date range: end must be after start
export const endAfterStart: ValidatorFn = (group) => {
  const start = group.get('start')?.value;
  const end = group.get('end')?.value;
  return start && end && end < start ? { endBeforeStart: true } : null;
};

// Async: returns an Observable. Pretend this asks a server.
export function usernameFree(taken: string[]): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const name = String(control.value ?? '').toLowerCase();
    if (name.length < 3) {
      return of(null);
    }
    return of(taken.includes(name)).pipe(
      delay(300), // network time
      map((isTaken) => (isTaken ? { usernameTaken: true } : null)),
    );
  };
}

Notes:

  • Return null for empty values and let Validators.required handle emptiness, so users do not see two messages for one problem.
  • The error value can carry details ({ required: 'ABC', actual: 'xyz' }) to show in the message.
  • A validator on a group sees all controls and can compare them. Its error appears on the group (form.errors), not on a single control.

Using a service in a validator

A validator is a plain function, so it cannot use inject() on its own. Create it inside a class that has the service, and let the function close over it:

// src/app/forms4/username.service.ts
import { Injectable } from '@angular/core';
import { Observable, delay, of } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UsernameService {
  private readonly taken = ['admin', 'root', 'ada'];

  isTaken(name: string): Observable<boolean> {
    return of(this.taken.includes(name.toLowerCase())).pipe(delay(300));
  }
}
// src/app/forms4/signup-form.ts
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { map } from 'rxjs';
import { UsernameService } from './username.service';
import { endAfterStart, noSpaces, passwordsMatch, startsWith } from './validators';

@Component({
  selector: 'app-signup-form',
  imports: [ReactiveFormsModule, JsonPipe],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()" novalidate>
      <p>
        <label for="username">Username</label><br />
        <input id="username" formControlName="username" />
        @if (username.pending) { <span role="status">Checking...</span> }
        @if (username.touched && username.hasError('usernameTaken')) { <span role="alert">That name is taken.</span> }
        @if (username.touched && username.hasError('noSpaces')) { <span role="alert">No spaces, please.</span> }
      </p>

      <p>
        <label for="code">Invite code (starts with INV-)</label><br />
        <input id="code" formControlName="code" />
        @if (form.controls.code.touched && form.controls.code.errors?.['startsWith']; as e) {
          <span role="alert">Must start with {{ e.required }}.</span>
        }
      </p>

      <p>
        <label for="password">Password</label><br />
        <input id="password" type="password" formControlName="password" />
      </p>
      <p>
        <label for="confirm">Confirm password</label><br />
        <input id="confirm" type="password" formControlName="confirm" />
        @if (form.touched && form.hasError('mismatch')) { <span role="alert">Passwords do not match.</span> }
      </p>

      <p>
        <label><input type="checkbox" formControlName="business" /> I am a business customer</label>
      </p>
      @if (form.controls.business.value) {
        <p>
          <label for="vat">VAT number</label><br />
          <input id="vat" formControlName="vat" />
        </p>
      }

      <button type="submit" [disabled]="form.invalid || form.pending">Create account</button>
    </form>
    <pre>{{ form.status }} {{ form.errors | json }}</pre>
  `,
})
export class SignupForm {
  private readonly fb = inject(FormBuilder).nonNullable;
  private readonly usernames = inject(UsernameService);

  // The validator is created here, so it can use the injected service.
  private readonly usernameAvailable: AsyncValidatorFn = (control: AbstractControl) =>
    this.usernames.isTaken(String(control.value ?? '')).pipe(map((taken) => (taken ? { usernameTaken: true } : null)));

  protected readonly form = this.fb.group(
    {
      username: this.fb.control('', {
        validators: [Validators.required, Validators.minLength(3), noSpaces],
        asyncValidators: [this.usernameAvailable], // run only when the sync validators pass
        updateOn: 'blur', // check when the user leaves the field, not on every key
      }),
      code: ['', [Validators.required, startsWith('INV-')]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirm: ['', Validators.required],
      business: [false],
      vat: [''],
    },
    { validators: [passwordsMatch] }, // group-level validator
  );

  protected get username() {
    return this.form.controls.username;
  }

  constructor() {
    // Change the rules at run time: VAT is required only for business customers.
    this.form.controls.business.valueChanges.subscribe((isBusiness) => {
      const vat = this.form.controls.vat;
      if (isBusiness) {
        vat.setValidators([Validators.required, Validators.minLength(8)]);
      } else {
        vat.clearValidators();
        vat.setValue('');
      }
      vat.updateValueAndValidity(); // IMPORTANT: re-run validation after changing validators
    });
  }

  protected submit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    console.log('Created', this.form.getRawValue());
  }
}
// src/app/app.ts
import { Component } from '@angular/core';
import { SignupForm } from './forms4/signup-form';

@Component({
  imports: [SignupForm],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Validation</h1>
  <app-signup-form />
</main>

The key ideas

  • Async validators return an Observable or Promise. While they run, the control's status is `PENDING`, so you can show "Checking..." and keep the submit button disabled (form.pending).
  • Async validators run only after all sync validators pass, which saves server calls.
  • `updateOn: 'blur'` (or 'submit') delays validation until the user finishes, which avoids a server call on every keystroke. You can also debounce inside the async validator.
  • `setValidators()` replaces the validators of a control at run time. It does not re-check the value, so call `updateValueAndValidity()` afterwards. addValidators(), removeValidators() and clearValidators() also exist.
  • Group validators go in the second argument of group(). The error is on the group, so read it with form.hasError('mismatch').
  • To show a cross-field error on a control, use the group error in the template, or set it on the control with setErrors().

Custom validators in template-driven forms

Template-driven forms cannot take a function directly in the template, so wrap the validator in a directive that provides NG_VALIDATORS:

// src/app/forms4/no-spaces.directive.ts
import { Directive } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
import { noSpaces } from './validators';

@Directive({
  selector: '[appNoSpaces]',
  providers: [{ provide: NG_VALIDATORS, useExisting: NoSpacesDirective, multi: true }],
})
export class NoSpacesDirective implements Validator {
  validate(control: AbstractControl): ValidationErrors | null {
    return noSpaces(control);
  }
}
// src/app/forms4/td-form.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NoSpacesDirective } from './no-spaces.directive';

@Component({
  selector: 'app-td-form',
  imports: [FormsModule, NoSpacesDirective],
  template: `
    <form #f="ngForm">
      <label for="nick">Nickname</label>
      <input id="nick" name="nick" ngModel appNoSpaces #nick="ngModel" />
      @if (nick.touched && nick.hasError('noSpaces')) { <span role="alert">No spaces allowed.</span> }
      <p>Form valid: {{ f.valid }}</p>
    </form>
  `,
})
export class TdForm {}

For a validator with a parameter in a template-driven form, add an input() to the directive, and read it in validate. An async validator directive provides NG_ASYNC_VALIDATORS and implements AsyncValidator.

Tests

// src/app/forms4/validators.spec.ts
import { FormControl, FormGroup } from '@angular/forms';
import { endAfterStart, noSpaces, passwordsMatch, startsWith, usernameFree } from './validators';

describe('custom validators', () => {
  it('noSpaces rejects spaces', () => {
    expect(noSpaces(new FormControl('a b'))).toEqual({ noSpaces: true });
    expect(noSpaces(new FormControl('ab'))).toBeNull();
  });

  it('startsWith takes a parameter and ignores empty values', () => {
    const v = startsWith('INV-');
    expect(v(new FormControl('INV-1'))).toBeNull();
    expect(v(new FormControl(''))).toBeNull();
    expect(v(new FormControl('X-1'))).toEqual({ startsWith: { required: 'INV-', actual: 'X-1' } });
  });

  it('passwordsMatch reports a group error', () => {
    const group = new FormGroup({ password: new FormControl('a'), confirm: new FormControl('b') });
    expect(passwordsMatch(group)).toEqual({ mismatch: true });
    group.get('confirm')!.setValue('a');
    expect(passwordsMatch(group)).toBeNull();
  });

  it('endAfterStart compares dates in a group', () => {
    const g = new FormGroup({ start: new FormControl('2026-10-10'), end: new FormControl('2026-10-01') });
    expect(endAfterStart(g)).toEqual({ endBeforeStart: true });
  });

  it('usernameFree is async and reports taken names', async () => {
    const control = new FormControl('admin');
    const result = await new Promise((resolve) => (usernameFree(['admin'])(control) as any).subscribe(resolve));
    expect(result).toEqual({ usernameTaken: true });
  });

  it('setValidators needs updateValueAndValidity', () => {
    const c = new FormControl('');
    expect(c.valid).toBe(true);
    c.setValidators([(x) => (x.value ? null : { required: true })]);
    expect(c.valid).toBe(true); // not re-checked yet
    c.updateValueAndValidity();
    expect(c.valid).toBe(false);
  });
});

The last test shows why updateValueAndValidity() is needed after setValidators().

Try it yourself

  1. Write maxWords(n), a validator with a parameter that limits the word count.
  2. Make endAfterStart show its error under the end date input.
  3. Add a debounce to the async validator with timer(300).pipe(switchMap(...)).

Quick check

  1. What does a validator return when the value is valid?
  2. Why does setValidators() need updateValueAndValidity()?
  3. Where does the error of a group validator appear?

Next, continue with Angular Routing Basics: Pages, RouterLink and RouterOutlet.

Frequently asked questions

Should validation happen on the client or the server?

Both. The client gives quick feedback, and the server must check again because users can bypass the browser.

Why do I see PENDING forever?

The async validator's Observable never completes. Make it emit once and complete, for example with an HTTP call, or add take(1) or first().