Web

Reactive Form Validation in Angular: Built-in, Custom and Async Validators

Web · Published 2026-10-27

In Angular reactive forms, validation lives in the component class: each FormControl receives an array of validator functions, the form tracks valid, errors and status for every control, and the template only displays the result. Use the built-in Validators for common rules, write a ValidatorFn for custom rules, attach group-level validators for rules that compare fields, and an AsyncValidatorFn for server checks.

The code targets current Angular (version 21 was used to run it): standalone components, typed forms and the built-in @if control flow. The validation logic shown was executed in Node.js against @angular/forms and type-checked in strict mode.

Set up a typed form

Import ReactiveFormsModule in the standalone component and build the form with FormBuilder. The nonNullable builder makes reset() return controls to their initial values instead of null, and lets TypeScript infer precise types for every value.

import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
  selector: 'app-signup',
  imports: [ReactiveFormsModule],
  templateUrl: './signup.component.html',
})
export class SignupComponent {
  private fb = inject(FormBuilder);

  form = this.fb.nonNullable.group({
    username: ['', {
      validators: [Validators.required, Validators.minLength(3), forbiddenNameValidator(/bob/i)],
      asyncValidators: [usernameAvailable],
      updateOn: 'blur' as const,
    }],
    email: ['', [Validators.required, Validators.email]],
    age: [0, [Validators.min(18), Validators.max(120)]],
    passwords: this.fb.nonNullable.group({
      password: ['', [Validators.required, Validators.minLength(12)]],
      confirm: ['', Validators.required],
    }, { validators: passwordsMatch }),
  });

  submit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched();   // reveal every error at once
      return;
    }
    console.log(this.form.getRawValue());
  }
}

Each control is declared as [initialValue, validators], or [initialValue, options] when you also need async validators or updateOn. Setting updateOn: 'blur' on the username means it is validated when the field loses focus, not on every keystroke, which matters for the async check below.

The built-in validators

ValidatorError keyError value
Validators.requiredrequiredtrue
Validators.requiredTruerequiredtrue (for checkboxes that must be ticked)
Validators.minLength(n) / maxLength(n)minlength / maxlength{ requiredLength, actualLength }
Validators.min(n) / max(n)min / max{ min, actual } / { max, actual }
Validators.emailemailtrue
Validators.pattern(re)pattern{ requiredPattern, actualValue }

Note the lowercase error keys for minlength and maxlength, a frequent source of messages that never appear. min, max, minLength and maxLength do not flag an empty value; combine them with required when the field is mandatory. Validators.email is a pragmatic syntax check; the server should still validate addresses, as in the Python email-validator guide.

A custom validator

A validator is a function that receives the control and returns null when the value is valid, or an object describing the error. Wrapping it in a factory lets you pass parameters:

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

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

Keep validators pure and synchronous: no HTTP calls and no side effects. They run on every value change of their control.

Cross-field validation on the group

Rules that involve several fields, like "the confirmation must match the password", belong on the parent FormGroup. The validator receives the group, reads its children, and the error appears on the group rather than on a single control:

export const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const pass = group.get('password')?.value;
  const confirm = group.get('confirm')?.value;
  return pass && confirm && pass !== confirm ? { passwordMismatch: true } : null;
};

An async validator

An AsyncValidatorFn returns an Observable or a Promise of the same ValidationErrors | null. Angular only runs it when all synchronous validators pass, and the control's status is PENDING while it waits. In a real app, inject a service and call your API; this stand-in simulates the round trip:

import { AsyncValidatorFn } from '@angular/forms';
import { of, delay, map } from 'rxjs';

const taken = ['admin', 'root'];

export const usernameAvailable: AsyncValidatorFn = (control) =>
  of(taken.includes(String(control.value).toLowerCase())).pipe(
    delay(300),
    map((isTaken) => (isTaken ? { usernameTaken: true } : null)),
  );

What the form reports

Feeding deliberately bad values into the form above shows how the errors are structured:

form.setValue({ username: 'Bo', email: 'not-an-email', age: 15,
  passwords: { password: 'correct-horse-1', confirm: 'correct-horse-2' } });
console.log(form.valid);
console.log(form.controls.username.errors);
console.log(form.controls.email.errors);
console.log(form.controls.age.errors);
console.log(form.controls.passwords.errors);

form.controls.username.setValue('admin');
console.log(form.controls.username.status);   // async check running
// ...after the async validator completes:
console.log(form.controls.username.status, form.controls.username.errors);
false
{ minlength: { requiredLength: 3, actualLength: 2 } }
{ email: true }
{ min: { min: 18, actual: 15 } }
{ passwordMismatch: true }
PENDING
INVALID { usernameTaken: true }

Showing errors in the template

Show a message only after the user has interacted with the field (touched or dirty), and connect it to the input for screen readers with aria-describedby and aria-invalid:

<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
  <label for="username">Username</label>
  <input id="username" formControlName="username"
         [attr.aria-invalid]="form.controls.username.invalid && form.controls.username.touched"
         aria-describedby="username-error">
  @if (form.controls.username.pending) {
    <p>Checking availability…</p>
  }
  @if (form.controls.username.touched && form.controls.username.errors; as errs) {
    <p id="username-error" role="alert">
      @if (errs['required']) { Choose a username. }
      @if (errs['minlength']) { Use at least {{ errs['minlength'].requiredLength }} characters. }
      @if (errs['forbiddenName']) { This name is not allowed. }
      @if (errs['usernameTaken']) { This username is already taken. }
    </p>
  }

  <div formGroupName="passwords">
    <label for="password">Password</label>
    <input id="password" type="password" formControlName="password">
    <label for="confirm">Confirm password</label>
    <input id="confirm" type="password" formControlName="confirm">
    @if (form.controls.passwords.hasError('passwordMismatch') && form.controls.passwords.touched) {
      <p role="alert">The passwords do not match.</p>
    }
  </div>

  <button type="submit">Create account</button>
</form>

Leaving the submit button enabled and calling markAllAsTouched() on an invalid submit is friendlier than a disabled button that gives no hint why. The novalidate attribute turns off the browser's own validation bubbles so Angular's messages are the only ones shown. The accessibility points here echo those in the guide to accessible websites for medical practices.

Changing validators at runtime

When one answer makes another field mandatory, use addValidators, removeValidators or setValidators, then call updateValueAndValidity(); validators are not re-run automatically after the change. Disabled controls are excluded from validation and from form.value; use getRawValue() to read them anyway.

A note on signal forms

Recent Angular versions also ship an experimental signal-based forms API. Reactive forms remain the stable, fully supported choice, and everything above applies to them unchanged.

Summary

Declare rules next to each control with Validators, write custom rules as ValidatorFn factories that return null or an error object, put cross-field rules on the group, use AsyncValidatorFn with updateOn: 'blur' for server checks, and render messages from errors once a control is touched, with ARIA attributes linking each message to its input.

Sources

Read next

More in Web