# Form Validation

Course: [Angular](https://www.devtutorials.ai/learn/angular) · Section: Angular Forms
Source: https://www.devtutorials.ai/learn/angular/forms/form-validation

> 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.

<figure className="ngc-fig">
<svg viewBox="0 0 640 140" role="img" aria-label="A value runs through its validators, failed rules produce an errors object, and the template shows a message for each error.">
<defs><marker id="ngc-f3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" className="ngc-arrowhead" /></marker></defs><g className="ngc-step ngc-t1"><g className="ngc-node ngc-soft c-purple"><rect x="6" y="36" width="140" height="60" rx="9" /><text x="76.0" y="63" textAnchor="middle" style={{fontSize:"15px"}}>'abc'</text><text className="ngc-sub" x="76.0" y="81" textAnchor="middle">value</text></g></g><line className="ngc-link ngc-flow c-slate" x1="148" y1="66" x2="164" y2="66" markerEnd="url(#ngc-f3)" /><g className="ngc-step ngc-t2"><g className="ngc-node ngc-solid c-orange"><rect x="166" y="36" width="140" height="60" rx="9" /><text x="236.0" y="63" textAnchor="middle" style={{fontSize:"15px"}}>Validators</text><text className="ngc-sub" x="236.0" y="81" textAnchor="middle">required, minLength(8)</text></g></g><line className="ngc-link ngc-flow c-slate" x1="308" y1="66" x2="324" y2="66" markerEnd="url(#ngc-f3)" /><g className="ngc-step ngc-t3"><g className="ngc-node ngc-soft c-red"><rect x="326" y="36" width="140" height="60" rx="9" /><text x="396.0" y="63" textAnchor="middle" style={{fontSize:"15px"}}>&#123; minlength &#125;</text><text className="ngc-sub" x="396.0" y="81" textAnchor="middle">errors object</text></g></g><line className="ngc-link ngc-flow c-slate" x1="468" y1="66" x2="484" y2="66" markerEnd="url(#ngc-f3)" /><g className="ngc-step ngc-t4"><g className="ngc-node ngc-soft c-green"><rect x="486" y="36" width="140" height="60" rx="9" /><text x="556.0" y="63" textAnchor="middle" style={{fontSize:"15px"}}>Message</text><text className="ngc-sub" x="556.0" y="81" textAnchor="middle">shown when touched</text></g></g><text className="ngc-note" x="320" y="124" textAnchor="middle">Valid values give errors = null. The form is valid only when every control is valid.</text>
</svg>
<figcaption>Validators turn a value into errors. The template turns errors into messages.</figcaption>
</figure>

## Validation Approaches

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

| Point | Template-driven | Reactive |
|---|---|---|
| Rules declared in | The template, as attributes | The class, as validator functions |
| Example | `<input name="email" ngModel required email>` | `email: ['', [Validators.required, Validators.email]]` |
| Read errors with | A reference: `#email="ngModel"` | The control: `form.controls.email` |
| Custom rules | Need a directive | A 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`.

```angular runnable title="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.

```angular runnable title="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.

| Rule | Template attribute | Reactive validator | Error key |
|---|---|---|---|
| Required | `required` | `Validators.required` | `required` |
| Minimum length | `minlength="8"` | `Validators.minLength(8)` | `minlength` |
| Maximum length | `maxlength="20"` | `Validators.maxLength(20)` | `maxlength` |
| Pattern | `pattern="[0-9]{6}"` | `Validators.pattern(/^[0-9]{6}$/)` | `pattern` |
| Email | `email` | `Validators.email` | `email` |
| Minimum / maximum number | `min="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.

```angular runnable title="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.

| Property | `true` when |
|---|---|
| `valid` | Every rule passes |
| `invalid` | At least one rule fails |
| `touched` | The user focused the field and then left it |
| `untouched` | The user has not left the field yet |
| `dirty` | The user changed the value |
| `pristine` | The user has not changed the value |
| `pending` | An 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.

```angular runnable title="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.

```angular runnable title="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.

```angular runnable title="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(...)`.

```angular runnable title="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:

| `updateOn` | Validates | Use it for |
|---|---|---|
| `'change'` (default) | On every key press | Most fields, live feedback |
| `'blur'` | When the user leaves the field | Expensive checks, fewer distracting messages |
| `'submit'` | When the form is submitted | Very long forms, server checks |

```angular runnable title="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.

```angular runnable title="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

<div className="ngc-mistakes">

| Mistake | What you see | Fix |
|---|---|---|
| Showing errors before interaction | Red messages on a fresh, empty form | Check `touched` or `dirty` before showing them |
| Wrong error key casing: `hasError('minLength')` | The message never appears | Error keys are lower case: `'minlength'`, `'maxlength'` |
| Cross-field rule on one control | The rule does not re-run when the other field changes | Put the validator on the `FormGroup` |
| Validator returns `false` when valid | The control is always invalid | Return `null` when the value is valid |
| Adding a validator without re-checking | The control keeps its old status | Call `updateValueAndValidity()` |
| Relying only on client validation | Bad data reaches the server through direct API calls | Validate again on the server |

</div>

## 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.
