Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

To validate a form in Angular, attach validators to each control in reactive forms, or add validation attributes to each input in template-driven forms. Angular then keeps a status and an errors object for every control and group, and your template reads those to decide what to display. The rules are the straightforward part. Deciding when a message should appear matters just as much for the user experience, and it is covered in its own section below.

Choose reactive or template-driven forms

Angular supports both approaches, and they differ mainly in where validation rules live and how much of the form you control in component code. Angular’s official forms guidance describes template-driven forms as suitable for small or simple forms and reactive forms as more scalable for complex ones.

Aspect Reactive forms Template-driven forms
Where validation rules live In the component’s FormControl and FormGroup definitions In validation attributes and directives on the template elements
Angular’s suggested fit Complex forms; described as more scalable Small or simple forms
How form state is accessed Synchronously, through the form model objects in the component Through template reference variables exported from ngModel
Changing rules at runtime Supported through AbstractControl methods such as setValidators and removeValidators Rules are declared as template attributes; the runtime methods above are described for reactive controls
Module to import ReactiveFormsModule FormsModule

Reactive forms are the better default when rules depend on other fields, change while the user works, or need to be read from component code. A three-input contact form is often shorter to write with template-driven forms. Both produce the same kind of status and errors objects, so the display approach described later works with either.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Built-in validators

Both approaches draw on the same set of rules. The table lists the reactive API, the matching template attribute, and the key that appears in the errors object.

Rule Reactive API Template attribute Error key
Value must be present Validators.required required required
Checkbox must be checked Validators.requiredTrue required on a checkbox input required
Valid email format Validators.email email email
Minimum number Validators.min(n) min min
Maximum number Validators.max(n) max max
Minimum length Validators.minLength(n) minlength minlength
Maximum length Validators.maxLength(n) maxlength maxlength
Matches a pattern Validators.pattern(regex or string) pattern pattern

Three details matter in practice. The checkbox rule reports its failure under the key required, not requiredTrue, so check hasError('required') for checkboxes. The minlength error object includes requiredLength and actualLength, which you can use in a message. A string passed to Validators.pattern is anchored to the whole value, so '[a-z]+' behaves as '^[a-z]+$'.

Reactive forms: validate in the component

  1. Import ReactiveFormsModule. In a standalone component, add it to the imports array; in an NgModule, import it in the module.
  2. Define the model in the component. Pass validators as an array in the second argument of each FormControl.
    import { Component } from '@angular/core';
    import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
    
    @Component({
      selector: 'app-signup',
      standalone: true,
      imports: [ReactiveFormsModule],
      templateUrl: './signup.component.html',
    })
    export class SignupComponent {
      form = new FormGroup({
        name: new FormControl('', [Validators.required, Validators.minLength(2)]),
        email: new FormControl('', [Validators.required, Validators.email]),
      });
    
      submit(): void {
        if (this.form.valid) {
          console.log(this.form.value);
        }
      }
    }
  3. Bind the model in the template: [formGroup] on the form element and formControlName on each input.
    <form [formGroup]="form" (ngSubmit)="submit()">
      <label>
        Name
        <input formControlName="name" />
      </label>
      @if (form.controls.name.invalid && (form.controls.name.touched || form.controls.name.dirty)) {
        @if (form.controls.name.hasError('required')) {
          <p class="error">Enter your name.</p>
        } @else if (form.controls.name.hasError('minlength')) {
          <p class="error">Use at least 2 characters.</p>
        }
      }
    
      <label>
        Email
        <input formControlName="email" type="email" />
      </label>
      @if (form.controls.email.invalid && (form.controls.email.touched || form.controls.email.dirty)) {
        <p class="error">Enter an email address such as name@example.com.</p>
      }
    
      <button type="submit">Sign up</button>
    </form>
  4. Read the state where you need it. control.invalid tells you whether the control currently fails any rule, control.errors holds the errors object or null, and control.hasError('key') checks a single key.

Validators re-run whenever a value changes, so form.valid and each control’s status stay current without extra calls.

Template-driven forms: validate in the template

  1. Import FormsModule. In a standalone component, add it to imports.
  2. Give each input a name attribute and an [(ngModel)] binding. Inside a form, the name is what lets Angular register the control.
  3. Add validation attributes such as required, minlength, or email. Angular maps each attribute to the matching validator.
  4. Export the control to a template reference variable with #nameCtrl="ngModel", then read its state in the template.
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-signup-simple',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './signup-simple.component.html',
})
export class SignupSimpleComponent {
  model = { name: '', email: '' };

  submit(): void {
    console.log(this.model);
  }
}
<form (ngSubmit)="submit()">
  <input name="name" [(ngModel)]="model.name" required minlength="2" #nameCtrl="ngModel" />
  @if (nameCtrl.invalid && (nameCtrl.touched || nameCtrl.dirty)) {
    <p class="error">Name is required and must be at least 2 characters.</p>
  }

  <input name="email" [(ngModel)]="model.email" required email #emailCtrl="ngModel" />
  @if (emailCtrl.invalid && (emailCtrl.touched || emailCtrl.dirty)) {
    <p class="error">Enter an email address.</p>
  }

  <button type="submit">Sign up</button>
</form>

When validation messages should appear

Showing every error on first render makes a form accuse the user before they have typed anything. Angular tracks two flags for this purpose:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • dirty becomes true once the user changes the value. pristine is its opposite, and stays true until that first change.
  • touched becomes true when the user focuses a field and then leaves it.

A common display rule is invalid && (touched || dirty), applied the same way to every field so the form behaves consistently. Forms that validate on submit often add their own submitted boolean, set in the submit handler, and show all errors once it is true. That flag is a convention you add yourself, not a built-in Angular state.

Custom validators

A validator is a function that receives a control and returns either null, meaning valid, or an object whose keys name the errors. How you attach it depends on the form approach.

A custom validator for reactive forms

This function rejects values that contain any word from a list. It returns a named error that includes the matched word.

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

export function forbiddenWords(words: string[]): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const text = String(control.value ?? '').toLowerCase();
    const hit = words.find(word => text.includes(word));
    return hit ? { forbiddenWord: { word: hit } } : null;
  };
}

Add it to the validator array with the built-in rules: new FormControl('', [Validators.required, forbiddenWords(['spam', 'scam'])]). Check it with hasError('forbiddenWord').

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A custom validator for template-driven forms

Template attributes cannot take a validator function directly, so wrap the logic in a directive and register it under the NG_VALIDATORS token. Angular collects every provider registered under that token and runs them with the other rules on the same input.

import { Directive, forwardRef, Input } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';

@Directive({
  selector: '[appForbiddenWord]',
  standalone: true,
  providers: [
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => ForbiddenWordDirective),
      multi: true,
    },
  ],
})
export class ForbiddenWordDirective implements Validator {
  @Input('appForbiddenWord') forbidden = '';

  validate(control: AbstractControl): ValidationErrors | null {
    const text = String(control.value ?? '').toLowerCase();
    const word = this.forbidden.toLowerCase();
    return word && text.includes(word) ? { forbiddenWord: true } : null;
  }
}
<input name="title" [(ngModel)]="post.title" appForbiddenWord="spam" #titleCtrl="ngModel" />

Cross-field validation

Some rules concern a combination of fields, such as a password and its confirmation. Put that rule on the FormGroup, because the group is the lowest level where both values are visible. The error then lives on the group, not on the confirmation control, which keeps the error key meaningful and avoids attaching a message to the wrong field.

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

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

// Inside the component
form = new FormGroup(
  {
    password: new FormControl('', [Validators.required, Validators.minLength(8)]),
    confirm: new FormControl('', [Validators.required]),
  },
  { validators: passwordsMatch }
);

Read the group’s error from the group, and show it beside the field the user is working on:

@if (form.hasError('passwordMismatch') && (form.controls.confirm.touched || form.controls.confirm.dirty)) {
  <p class="error">The passwords do not match.</p>
}

The function returns null while either field is empty, so the required rules on each field handle empty input and the mismatch message appears only when both values exist.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Async validators

Use an async validator when the answer depends on work outside the form, such as checking whether a username is already registered. Three behaviors shape how you write them:

  • Angular runs async validators only after every synchronous validator on that control has passed.
  • While the check runs, the control’s status is PENDING, which you can read with control.pending.
  • The validator returns a Promise or an Observable. Make the Observable finish after one result, for example with first(), so each run has a clear end.
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, first, map, switchMap, timer } from 'rxjs';

export function uniqueUsername(
  isTaken: (name: string) => Observable<boolean>
): AsyncValidatorFn {
  return (control: AbstractControl) =>
    timer(300).pipe(
      switchMap(() => isTaken(String(control.value ?? ''))),
      map((taken): ValidationErrors | null => (taken ? { usernameTaken: true } : null)),
      first()
    );
}

// Inside the component, with this.users injected as a service
username = new FormControl('', [Validators.required], [
  uniqueUsername(name => this.users.isTaken(name)),
]);

The timer(300) delay means a lookup starts only after typing pauses for 300 milliseconds. Each new value cancels the previous run, so this keeps the number of requests down without extra code.

@if (form.controls.username.pending) {
  <p>Checking availability…</p>
}
@if (form.controls.username.hasError('usernameTaken')) {
  <p class="error">That username is already taken.</p>
}

Add and remove validators at runtime

Conditional rules are common. A shipping address is required only when the delivery method is “ship”. Reactive controls expose setValidators, addValidators, removeValidators, and clearValidators through AbstractControl. Changing the validator list does not re-check the value by itself, so call updateValueAndValidity() afterwards.

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

// Inside the constructor
const method = this.form.controls.method;
const address = this.form.controls.address;

method.valueChanges
  .pipe(takeUntilDestroyed())
  .subscribe(value => {
    if (value === 'ship') {
      address.setValidators([Validators.required]);
    } else {
      address.clearValidators();
    }
    address.updateValueAndValidity();
  });

Troubleshooting

  • An error never appears. Confirm the display condition includes touched or dirty, and log control.errors to see whether the rule fires at all.
  • formControlName is ignored or throws. The parent element needs [formGroup], and ReactiveFormsModule must be imported in the component or module.
  • Template-driven rules have no effect. FormsModule must be imported, and each input needs a name attribute inside the form.
  • A cross-field message does not show on a field. Group errors live on the group. Read them with form.hasError() rather than from a single control.
  • An async field stays pending. Check that the lookup emits a value. A service call that never responds keeps the control pending.
  • Changed validators have no effect on validity. Call updateValueAndValidity() after changing the rule list.

Scope and version notes

The examples use standalone components and the @if and @else control-flow syntax, which apply to Angular 17 and later. Confirm the API names against the documentation for the Angular version in your project before copying code.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Angular’s official documentation also covers validation in Signal Forms, a separate forms approach. This article covers reactive and template-driven forms only and does not compare Signal Forms with them.

Everything here runs in the browser. Client-side validators make a form easier to complete, but they cannot protect your server, because a request can bypass them. Enforce the same rules on the server. This article does not cover server-side validation or security practice.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.