Angular Form Validation: Built-in and Custom Validators

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

A form that accepts anything causes problems later. Validation checks the input and tells the user what to fix. Angular makes this part of the form itself, so the state of each control is always known.

What you will learn

  • How to add built-in validators
  • How to show error messages only when they are useful
  • How to write a custom validator and a rule across two fields
  • How to keep error messages accessible

Built-in validators

Validators are functions from Validators. You pass them as the second item when you define a control:

  • Validators.required: a value must be present
  • Validators.email: the value must look like an email address
  • Validators.minLength(n) and Validators.maxLength(n)
  • Validators.min(n) and Validators.max(n) for numbers
  • Validators.pattern(regex): the value must match
name: ['', [Validators.required, Validators.minLength(2)]],

Control state

Every control tells you about itself:

  • invalid or valid: do the rules pass?
  • touched: has the user visited the field and left it?
  • dirty: has the value changed?
  • errors: an object listing which rules failed, such as { required: true }

Showing errors right away on an empty form feels aggressive. The usual approach is to wait until the field is touched, or the form has been submitted.

A registration form

// src/app/validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// custom validator for one control: no spaces allowed
export const noSpaces: ValidatorFn = (control: AbstractControl): ValidationErrors | null =>
  /\s/.test(control.value ?? '') ? { noSpaces: true } : null;

// validator for a whole group: two controls must match
export const fieldsMatch =
  (first: string, second: string): ValidatorFn =>
  (group: AbstractControl): ValidationErrors | null => {
    const a = group.get(first)?.value;
    const b = group.get(second)?.value;
    return a === b ? null : { mismatch: true };
  };

A validator is a function that receives the control. It returns null when the value is fine, or an object describing the problem.

// src/app/register-form/register-form.ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { fieldsMatch, noSpaces } from '../validators';

@Component({
  selector: 'app-register-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()" novalidate>
      <p>
        <label for="username">Username</label><br />
        <input id="username" formControlName="username" aria-describedby="username-error" />
        <span id="username-error" role="alert">
          @if (show('username')) {
            @if (form.controls.username.errors?.['required']) { Username is required. }
            @else if (form.controls.username.errors?.['minlength']) { Use at least 3 characters. }
            @else if (form.controls.username.errors?.['noSpaces']) { Spaces are not allowed. }
          }
        </span>
      </p>

      <p>
        <label for="email">Email</label><br />
        <input id="email" type="email" formControlName="email" aria-describedby="email-error" />
        <span id="email-error" role="alert">
          @if (show('email')) {
            @if (form.controls.email.errors?.['required']) { Email is required. }
            @else if (form.controls.email.errors?.['email']) { Enter a valid email address. }
          }
        </span>
      </p>

      <p>
        <label for="password">Password</label><br />
        <input id="password" type="password" formControlName="password" aria-describedby="password-error" />
        <span id="password-error" role="alert">
          @if (show('password')) {
            @if (form.controls.password.errors?.['required']) { Password is required. }
            @else if (form.controls.password.errors?.['minlength']) { Use at least 8 characters. }
          }
        </span>
      </p>

      <p>
        <label for="confirm">Confirm password</label><br />
        <input id="confirm" type="password" formControlName="confirm" aria-describedby="confirm-error" />
        <span id="confirm-error" role="alert">
          @if (form.controls.confirm.touched && form.errors?.['mismatch']) { Passwords do not match. }
        </span>
      </p>

      <button type="submit" [disabled]="form.invalid && submitted()">Create account</button>
    </form>

    @if (done()) {
      <p role="status">Account created for {{ done() }}.</p>
    }
  `,
})
export class RegisterForm {
  private readonly fb = inject(FormBuilder);
  protected readonly submitted = signal(false);
  protected readonly done = signal('');

  protected readonly form = this.fb.nonNullable.group(
    {
      username: ['', [Validators.required, Validators.minLength(3), noSpaces]],
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirm: [''],
    },
    { validators: fieldsMatch('password', 'confirm') },
  );

  // show an error once the field has been visited, or the user tried to submit
  protected show(name: 'username' | 'email' | 'password'): boolean {
    const control = this.form.controls[name];
    return control.invalid && (control.touched || this.submitted());
  }

  protected submit(): void {
    this.submitted.set(true);
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    this.done.set(this.form.getRawValue().username);
    this.form.reset();
    this.submitted.set(false);
  }
}
// src/app/app.ts
import { Component } from '@angular/core';
import { RegisterForm } from './register-form/register-form';

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

What to notice

  • novalidate on the form turns off the browser's own pop-up messages, so Angular's messages are the only ones.
  • show() decides when to display errors: after the user leaves the field, or after a failed submit.
  • form.errors holds errors of the whole group, such as the password mismatch.
  • markAllAsTouched() makes every error visible when someone presses Submit on an untouched form.
  • The ?.['required'] syntax reads a property from the errors object safely, because errors can be null.

Accessible errors

Validation messages are only useful if everyone can perceive them:

  • Give every input a real <label> linked with for and id.
  • Connect the message with aria-describedby, so a screen reader reads it with the field.
  • Use role="alert" so that new messages are announced.
  • Do not rely on colour alone. Always include text.

Try it yourself

  1. Add a phone field with Validators.pattern(/^\d{10}$/) and an error message.
  2. Add a rule that the password must contain a number, as a custom validator.
  3. Disable the submit button until the form is valid, and compare how that feels with the current approach.

Quick check

  1. What does a validator return when the value is valid?
  2. Why wait for touched before showing an error?
  3. Where do errors from a rule across two fields live?

Finally, see the older, template-based approach in Template-driven forms. The form validation guide has more validators.

Frequently asked questions

Should I also validate on the server?

Always. Browser validation helps users, but anyone can bypass it. The server must check every value again.

Can validators run asynchronously?

Yes. Async validators return an observable or promise, which is useful for checks like "is this username taken?". They are an advanced topic you can add later.