Form Validation in Angular

By Syncfusion®, Inc. · Last updated

Validating input with built-in and custom validators, control states, clear error messages, cross-field, real-time and conditional validation, and best practices.

Introduction to Form Validation

Form validation checks that the values a user enters follow your rules before the app uses them. Examples of rules are "this field is required", "at least 8 characters" and "must be a valid email address".

Angular runs the rules every time a value changes. It marks each control and the whole form as valid or invalid, and stores which rules failed, so the template can show precise error messages.

Real-World Example

On an account registration page, the email must have a valid format, the password needs at least 8 characters, and the confirm-password field must match the password. A GST number is required only when the user selects "Business account".

The Create account button stays disabled until every rule passes, and each field shows its own message, such as "Password must be at least 8 characters".

Client-side validation improves the user experience, but it does not protect your data. A user can bypass the browser, so the server must validate every value again.

'abc'valueValidatorsrequired, minLength(8){ minlength }errors objectMessageshown when touchedValid values give errors = null. The form is valid only when every control is valid.

Validators turn a value into errors. The template turns errors into messages.

Validation Approaches

Angular supports validation in both form styles. The rules are the same. Only the place where you declare them changes.

PointTemplate-drivenReactive
Rules declared inThe template, as attributesThe class, as validator functions
Example<input name="email" ngModel required email>email: ['', [Validators.required, Validators.email]]
Read errors withA reference: #email="ngModel"The control: form.controls.email
Custom rulesNeed a directiveA plain function

Template-Driven Validation

In a template-driven form, you add standard HTML validation attributes to the field. Angular reads them and turns them into validators: required, minlength, maxlength, pattern and email.

Template-driven validation uses HTML attributes

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-root',
  imports: [FormsModule],
  template: `
    <form #f="ngForm">
      <input name="username" [(ngModel)]="username" #usernameCtrl="ngModel" required minlength="4" />
      @if (usernameCtrl.errors?.['required']) { <p class="error">Username is required.</p> }
      @if (usernameCtrl.errors?.['minlength']) { <p class="error">Username needs at least 4 characters.</p> }
    </form>
    <p>Form valid: {{ f.valid }}</p>
  `,
  styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
  username = 'ab';
}

Reactive Form Validation

In a reactive form, you pass validator functions from the Validators class when you create each control. The second item of a FormBuilder control array holds them.

Reactive validation uses Validators in the class

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

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <input formControlName="username" />
      @if (form.controls.username.hasError('required')) { <p class="error">Username is required.</p> }
      @if (form.controls.username.hasError('minlength')) { <p class="error">Username needs at least 4 characters.</p> }
    </form>
    <p>Form valid: {{ form.valid }}</p>
  `,
  styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    username: ['ab', [Validators.required, Validators.minLength(4)]],
  });
}

Both blocks apply the same two rules. Type more characters and the message disappears.

Built-in Validators

Angular provides validators for the most common rules. Each one adds a named error to the control when its rule fails.

RuleTemplate attributeReactive validatorError key
RequiredrequiredValidators.requiredrequired
Minimum lengthminlength="8"Validators.minLength(8)minlength
Maximum lengthmaxlength="20"Validators.maxLength(20)maxlength
Patternpattern="[0-9]{6}"Validators.pattern(/^[0-9]{6}$/)pattern
EmailemailValidators.emailemail
Minimum / maximum numbermin="1" / max="10"Validators.min(1) / Validators.max(10)min / max

Error keys are always lower case, even when the validator name is not. Validators.minLength(8) adds the error minlength, so check hasError('minlength').

Required Validation

required fails when the field is empty. For a checkbox, use Validators.requiredTrue to require it to be ticked, for example "I accept the terms".

MinLength Validation

minLength(n) fails when the text has fewer than n characters. The error object contains requiredLength and actualLength, which you can show in the message.

MaxLength Validation

maxLength(n) fails when the text has more than n characters. The template attribute maxlength also stops the browser from accepting more characters.

Pattern Validation

pattern(regex) fails when the value does not match a regular expression. Use it for formats such as PIN codes, phone numbers or product codes.

Email Validation

email fails when the value does not look like an email address. It checks the format only. It cannot check that the mailbox exists.

Built-in validators and their error details

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

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule, JsonPipe],
  template: `
    <form [formGroup]="form">
      <p>Name <input formControlName="name" /> {{ form.controls.name.errors | json }}</p>
      <p>Password <input formControlName="password" /> {{ form.controls.password.errors | json }}</p>
      <p>Bio <input formControlName="bio" /> {{ form.controls.bio.errors | json }}</p>
      <p>PIN code <input formControlName="pin" /> {{ form.controls.pin.errors | json }}</p>
      <p>Email <input formControlName="email" /> {{ form.controls.email.errors | json }}</p>
    </form>
  `,
})
export class App {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    name: ['', Validators.required],
    password: ['abc', Validators.minLength(8)],
    bio: ['A very long biography', Validators.maxLength(10)],
    pin: ['56001', Validators.pattern(/^[0-9]{6}$/)],
    email: ['asha@', Validators.email],
  });
}

Each line shows the control's errors object. Fix a value and its errors change to null.

Form Control States

Every control, group and form has state properties that change as the user interacts. Use them to decide when to show messages and whether to allow submission.

Propertytrue when
validEvery rule passes
invalidAt least one rule fails
touchedThe user focused the field and then left it
untouchedThe user has not left the field yet
dirtyThe user changed the value
pristineThe user has not changed the value
pendingAn asynchronous validator is still running

valid and invalid

These report the result of the rules. A form is valid only when every control in it is valid.

touched and untouched

These report whether the user has visited the field. A field becomes touched on blur, when focus leaves it.

dirty and pristine

These report whether the user has changed the value. Setting a value from code does not make a control dirty.

pending

pending is true while an asynchronous validator runs, such as a server check that a username is still free. During this time, the control is neither valid nor invalid.

Disable the submit button while the form is pending as well as invalid. Otherwise the user can submit before the server check has finished.

Control states change as you use the field

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

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="email" placeholder="Enter an email" />
    <table border="1" cellpadding="4">
      <tr><td>valid</td><td>{{ email.valid }}</td><td>invalid</td><td>{{ email.invalid }}</td></tr>
      <tr><td>touched</td><td>{{ email.touched }}</td><td>untouched</td><td>{{ email.untouched }}</td></tr>
      <tr><td>dirty</td><td>{{ email.dirty }}</td><td>pristine</td><td>{{ email.pristine }}</td></tr>
      <tr><td>pending</td><td>{{ email.pending }}</td><td>status</td><td>{{ email.status }}</td></tr>
    </table>
  `,
})
export class App {
  email = new FormControl('', [Validators.required, Validators.email]);
}

Click in the field, type an address and click outside. Watch each state change.

Validation Error Messages

Good messages appear at the right time and say exactly how to fix the value.

  • Show messages after the user has interacted, usually when the control is touched or dirty. A form full of red errors before the user types anything feels hostile.
  • Show one message per failed rule, using hasError('ruleName').
  • Use the error details in the text, such as the required length.
  • Show all messages on submit. Call form.markAllAsTouched() when the user submits an invalid form.

Messages appear after interaction and on submit

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

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <p><input formControlName="password" type="password" placeholder="Password" /></p>
      @if (password.touched && password.hasError('required')) {
        <p class="error">Password is required.</p>
      }
      @if (password.touched && password.hasError('minlength')) {
        <p class="error">
          Password must be at least {{ password.getError('minlength').requiredLength }} characters
          (you entered {{ password.getError('minlength').actualLength }}).
        </p>
      }
      <button type="submit">Create account</button>
    </form>
    <p>{{ status }}</p>
  `,
  styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
  private fb = inject(FormBuilder);
  form = this.fb.group({ password: ['', [Validators.required, Validators.minLength(8)]] });
  status = '';

  get password() {
    return this.form.controls.password;
  }

  submit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      this.status = 'Please fix the errors above.';
      return;
    }
    this.status = 'Account created';
  }
}

Click Create account without typing. markAllAsTouched() reveals the message.

Custom Validators

A custom validator is a function that checks a rule the built-in validators do not cover.

The function receives the control and returns:

  • null when the value is valid.
  • An error object, such as { noSpaces: true }, when it is invalid. The key becomes the error name you check with hasError('noSpaces').

A validator that needs settings, such as a list of forbidden words, is written as a function that returns a validator function.

Custom validators check rules of your own

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

export function noSpaces(control: AbstractControl): ValidationErrors | null {
  return String(control.value ?? '').includes(' ') ? { noSpaces: true } : null;
}

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

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <input formControlName="username" />
    </form>
    @if (form.controls.username.hasError('noSpaces')) { <p class="error">Username cannot contain spaces.</p> }
    @if (form.controls.username.hasError('forbidden')) {
      <p class="error">"{{ form.controls.username.getError('forbidden').word }}" is not allowed in a username.</p>
    }
  `,
  styles: `.error { color: #dc2626; margin: 4px 0; }`,
})
export class App {
  private fb = inject(FormBuilder);
  form = this.fb.group({
    username: ['admin user', [noSpaces, forbiddenWords(['admin', 'root'])]],
  });
}

The starting value breaks both rules, so both messages appear. Change it to asha_k and both disappear.

Cross-Field Validation

Cross-field validation checks a rule that involves more than one field, such as "confirm password must match password" or "end date must be after start date".

Put the validator on the group, not on a single control. The function receives the group and can read every control in it. The error is stored on the group, so check it with form.hasError(...).

A group validator compares two fields

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

export function passwordsMatch(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirm = group.get('confirm')?.value;
  return password === confirm ? null : { mismatch: true };
}

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <p><input formControlName="password" placeholder="Password" /></p>
      <p><input formControlName="confirm" placeholder="Confirm password" /></p>
    </form>
    @if (form.hasError('mismatch')) {
      <p class="error">Passwords do not match.</p>
    } @else {
      <p class="ok">Passwords match.</p>
    }
  `,
  styles: `.error { color: #dc2626; } .ok { color: #16a34a; }`,
})
export class App {
  private fb = inject(FormBuilder);
  form = this.fb.group(
    { password: ['Secret#2026'], confirm: ['Secret#2025'] },
    { validators: passwordsMatch },
  );
}

Real-Time Validation

By default, Angular validates on every change, so messages update as the user types. You can change when a control updates and validates with the updateOn option:

updateOnValidatesUse it for
'change' (default)On every key pressMost fields, live feedback
'blur'When the user leaves the fieldExpensive checks, fewer distracting messages
'submit'When the form is submittedVery long forms, server checks

Compare validation on change and on blur

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

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <p>On change: <input [formControl]="live" /> {{ live.valid ? '✅' : '❌' }}</p>
    <p>On blur: <input [formControl]="onBlur" /> {{ onBlur.valid ? '✅' : '❌' }}</p>
    <p>Type a valid email in each field, then click outside the second one.</p>
  `,
})
export class App {
  live = new FormControl('', [Validators.required, Validators.email]);
  onBlur = new FormControl('', { validators: [Validators.required, Validators.email], updateOn: 'blur' });
}

Conditional Validation

Conditional validation applies a rule only when another value makes it necessary. For example, a GST number is required only for business accounts.

Add or remove validators at run time, then call updateValueAndValidity() so Angular checks the control again with the new rules.

A rule that applies only for business accounts

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

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form">
      <label><input type="radio" formControlName="type" value="personal" (change)="updateGstRule()" /> Personal</label>
      <label><input type="radio" formControlName="type" value="business" (change)="updateGstRule()" /> Business</label>
      <p><input formControlName="gst" placeholder="GST number" /></p>
    </form>
    <p>GST required: {{ form.controls.gst.hasValidator(required) }} · Form valid: {{ form.valid }}</p>
  `,
})
export class App {
  private fb = inject(FormBuilder);
  required = Validators.required;
  form = this.fb.group({ type: ['personal'], gst: [''] });

  updateGstRule() {
    const gst = this.form.controls.gst;
    if (this.form.controls.type.value === 'business') {
      gst.addValidators(Validators.required);
    } else {
      gst.removeValidators(Validators.required);
    }
    gst.updateValueAndValidity();
  }
}

Select Business and the empty GST field makes the form invalid. Select Personal and the rule is removed.

Form Validation Best Practices

  • Validate on the server too. Client-side rules improve the experience but can be bypassed.
  • Show messages after interaction, using touched or dirty, and reveal all of them on submit with markAllAsTouched().
  • Write specific messages that say how to fix the value, such as "at least 8 characters".
  • Disable submit, or explain why it failed. Either keep the button disabled while the form is invalid, or allow the click and show every error.
  • Put cross-field rules on the group, not on one of the fields.
  • Keep custom validators pure. They should only read the value and return a result.
  • Call updateValueAndValidity() after adding or removing validators.
  • Use updateOn: 'blur' for fields where messages while typing would distract the user.

Common Mistakes

MistakeWhat you seeFix
Showing errors before interactionRed messages on a fresh, empty formCheck touched or dirty before showing them
Wrong error key casing: hasError('minLength')The message never appearsError keys are lower case: 'minlength', 'maxlength'
Cross-field rule on one controlThe rule does not re-run when the other field changesPut the validator on the FormGroup
Validator returns false when validThe control is always invalidReturn null when the value is valid
Adding a validator without re-checkingThe control keeps its old statusCall updateValueAndValidity()
Relying only on client validationBad data reaches the server through direct API callsValidate again on the server

Check Yourself

Exercises

What to Remember

  • Validators check user input. Template-driven forms use attributes such as required. Reactive forms use Validators functions in the class.
  • Read the result with valid, invalid, touched, dirty, hasError() and getError(), and show specific messages after the user interacts.
  • Custom validators return null or an error object. Cross-field rules go on the FormGroup, and conditional rules are added or removed at run time.

Next: Services, how to share data and logic between components with Angular services.