Angular Form Validation Deep Dive: Built-in, Custom, Async, Cross-Field [2026]

Link copied
Angular Form Validation Deep Dive: Built-in, Custom, Async, Cross-Field [2026]

Angular Form Validation Deep Dive: Built-in, Custom, Async, Cross-Field [2026]

Angular Tutorial Module 4: Forms Lesson 4.4

Validation is what separates a form from a div with inputs in it. Angular gives you three layers — built-in synchronous validators, custom synchronous validators, and asynchronous validators — and each works across all three forms eras (signal, reactive, template-driven). This lesson walks every layer with consistent shape across the eras.

This is lesson 4.4 of the Angular Tutorial, following lessons on each forms era. By the end you will be able to validate any form input correctly the first time.

The validation contract #

A validator is a function that takes a value (or a control) and returns either:

  • null — value is valid
  • { errorKey: payload } — value is invalid with that error key

That shape is consistent across all three forms APIs:

// Signal forms
const v = (value: string) => value.length >= 8 ? null : { tooShort: true };

// Reactive forms
const v = (c: AbstractControl) => c.value.length >= 8 ? null : { tooShort: true };

// Template-driven (wrapped in a directive)
@Directive({ selector: '[appMinLen]', providers: [...NG_VALIDATORS...] })
class MinLenDir implements Validator {
  validate(c: AbstractControl) { return c.value.length >= 8 ? null : { tooShort: true }; }
}

Same logic, three syntaxes. The error key (tooShort) is what the UI checks via errors.tooShort.

Built-in validators — coverage table #

Validator Use for Example
required() Field must have a non-empty value required()
email() Value matches RFC-5322-like email shape email()
minLength(n) / maxLength(n) String/array length bounds minLength(8)
min(n) / max(n) Numeric bounds min(0), max(120)
pattern(re) Regex match (anchored start-to-end) pattern(/^[A-Z0-9]+$/)

That's the lot. Anything else — phone numbers, credit cards, dates, URLs, custom business rules — is a custom validator.

The built-in email() validator is permissive (matches most real emails plus some weird ones). For strict validation, use a pattern with your own regex.

Custom sync validators (signal forms) #

The custom() helper takes a function:

import { field, required, custom } from '@angular/forms/signals';

const noSpaces = custom<string>(value =>
  /\s/.test(value) ? { hasSpaces: true } : null
);

username = field('', [required(), noSpaces]);

Usage in the template:

@if (form.username().errors().hasSpaces) {
  <p>Username cannot contain spaces</p>
}

Cross-field validators (signal forms) #

The custom helper's second argument is the form context — use it to read other fields:

const passwordsMatch = custom<string>((value, ctx) =>
  value === ctx.form.password().value() ? null : { mismatch: true }
);

confirmPassword = field('', [required(), passwordsMatch]);

The validator fires whenever either password OR confirmPassword changes (signals track both reads). The error lands on confirmPassword.

For errors that aren't field-specific ("end date must be after start date" — neither is wrong on its own), validate at the form level:

this.form = form({
  start: field<Date | null>(null, [required()]),
  end:   field<Date | null>(null, [required()]),
}, {
  validate: (value) => value.start && value.end && value.end < value.start
    ? { endBeforeStart: true }
    : null,
});

// Template
@if (form.errors().endBeforeStart) { <p>End must be after start</p> }

Async validators #

For server-side checks — "is this username available?" — use asyncValidator():

import { asyncValidator } from '@angular/forms/signals';

const uniqueUsername = asyncValidator<string>(async (value) => {
  if (!value) return null;
  const r = await fetch(`/api/users/check?name=${value}`).then(r => r.json());
  return r.taken ? { taken: true } : null;
});

username = field('', [required(), minLength(3), uniqueUsername]);

Three behaviors:

  1. Debounced — Angular collapses rapid changes; one server request per stable value
  2. Cancellable — typing while a request is in flight aborts it
  3. Pending statefield.pending() is true while running

In the template:

<input [value]="form.username().value()" (input)="form.username().value.set($any($event.target).value)" />
@if (form.username().pending()) { <span>Checking...</span> }
@else if (form.username().errors().taken) { <p>Username already taken</p> }

Showing errors at the right time #

A design problem more than an API one: showing every validation error on first render is hostile. The convention:

  • Show field-level errors after the user blurs the field (touched)
  • Show form-level errors after the user has submitted the form (submitted)
  • Show async-validator errors after they resolve (don't flash old errors)
@if (form.email().touched() && form.email().errors().email) {
  <p>Invalid email</p>
}

@if (form.errors().endBeforeStart && submitted()) {
  <p>End must be after start</p>
}

For a richer pattern, gate ALL error display on (touched && dirty):

@if (form.email().touched() && form.email().dirty() && form.email().errors().required) {
  <p>Email required</p>
}

Composing validators #

A field accepts an array of validators. They run in order; ALL non-null errors merge into the .errors() object:

email = field('', [
  required(),       // { required: true } if empty
  email(),          // { email: true } if not valid email
  custom(v => v.endsWith('@example.com') ? { wrongDomain: true } : null),
]);

// If empty, errors === { required: true, email: true, wrongDomain: true }
// (all three trigger because empty value fails all three)

Most validators short-circuit on empty values (the required validator alone reports empty; others skip empty input). Custom validators should do the same:

custom(v => !v ? null : /* check */)

Validator ordering and dependencies #

Validators in the array run independently. If you need them to short-circuit (don't run pattern if required failed), wrap with a conditional:

custom(v => {
  if (!v) return null;                         // skip if empty (required handles)
  return /pattern/.test(v) ? null : { pattern: true };
});

This is the convention rather than first-class API support. Validators are pure functions; ordering doesn't matter for behavior, only for UI display priority.

Asynchronous form-level validation #

For checks that depend on multiple fields hitting the server:

this.form = form({
  email: field('', [required(), email()]),
  password: field('', [required(), minLength(8)]),
}, {
  asyncValidate: async (value) => {
    const r = await fetch('/api/check-credentials', {
      method: 'POST',
      body: JSON.stringify(value),
    }).then(r => r.json());
    return r.ok ? null : { invalidCredentials: true };
  },
});

Fires once when the whole form is dirty + valid (all sync validators pass). Useful for "final check before submit" flows.

Validation in Reactive Forms (compact summary) #

For migration reference:

// Sync
email: ['', [Validators.required, Validators.email, customSyncValidator]],

// Async
username: ['', { validators: [Validators.required], asyncValidators: [uniqueUsernameAsync] }],

// Cross-field (form-level)
this.fb.group({
  password: [''],
  confirm: [''],
}, { validators: passwordsMatchValidator });

// Custom validator signature
function noSpaces(c: AbstractControl): { hasSpaces: true } | null {
  return /\s/.test(c.value) ? { hasSpaces: true } : null;
}

Same patterns, slightly different shape. The mental model is identical.

Validation in Template-Driven Forms (compact summary) #

Built-in: just the HTML attributes (required, email, minlength, pattern, etc.).

Custom: a Validator-implementing directive. See lesson 4.3.

Async custom validator: same directive shape but implementing AsyncValidator instead.

Common gotchas #

Symptom Cause Fix
Validator fires on empty input Validator didn't short-circuit Add if (!value) return null at the top
Errors object reads undefined .errors() returns null when valid Use .errors().key only after checking validity OR use optional chaining: .errors()?.key
Async validator fires on every keystroke Default debouncing not happening in your setup Signal forms debounce by default; reactive forms need valueChanges.pipe(debounceTime)
Cross-field validator doesn't re-fire Read the dependent field via a non-signal API In signal forms, read via ctx.form.x().value() (signal); the dependency is tracked automatically
Errors show before user interacts Showing errors regardless of touched Gate display on touched() or dirty()

What's next #

Lesson 4.5 covers file uploads with progress reporting. Lesson 4.6 closes Module 4 with typed reactive forms (FormGroup<T> strict mode for legacy code migration).

Try it yourself #

A signup form using built-in + custom + cross-field + async validators:

import { Component, signal } from '@angular/core';
import { form, field, required, email, minLength, custom, asyncValidator } from '@angular/forms/signals';

@Component({
  selector: 'app-signup',
  template: `
    <form (submit)="submit($event)">
      <input placeholder="Email" [value]="f.email().value()" (input)="f.email().value.set($any($event.target).value)" />
      @if (f.email().touched() && f.email().errors().email) { <p>Invalid email</p> }

      <input placeholder="Username" [value]="f.username().value()" (input)="f.username().value.set($any($event.target).value)" />
      @if (f.username().pending()) { <span>Checking...</span> }
      @else if (f.username().errors().taken) { <p>Already taken</p> }

      <input type="password" placeholder="Password" [value]="f.password().value()" (input)="f.password().value.set($any($event.target).value)" />
      <input type="password" placeholder="Confirm" [value]="f.confirm().value()" (input)="f.confirm().value.set($any($event.target).value)" />
      @if (f.confirm().errors().mismatch) { <p>Passwords don't match</p> }

      <button type="submit" [disabled]="!f.valid()">Create account</button>
    </form>
  `,
})
export class SignupComponent {
  f = form({
    email: field('', [required(), email()]),
    username: field('', [required(), minLength(3), asyncValidator(async v => {
      const r = await fetch(`/api/check?u=${v}`).then(r => r.json());
      return r.taken ? { taken: true } : null;
    })]),
    password: field('', [required(), minLength(8)]),
    confirm: field('', [required(), custom((v, ctx) => v === ctx.form.password().value() ? null : { mismatch: true })]),
  });

  submit(e: Event) {
    e.preventDefault();
    if (this.f.valid()) console.log('Signing up:', this.f.value());
  }
}

Four fields, built-in + custom + cross-field + async validation, one signal-driven form. About 30 lines.

YouWhen should I show validation errors — on first render, on blur, only on submit?
YouThe convention: field-level errors on blur (touched), form-level errors on submit, async errors when they resolve. Never on first render — “Email is required” before the user has typed anything is hostile UX. Showing errors during typing (no blur required) is fine ONLY for help text like “7 of 8 characters” — full “this is invalid” should wait for the user to indicate they’re done with the field.

Lesson 4.5 picks up file uploads with progress reporting.

Angular Tutorial · Lesson 4.4
← Previous lesson Template-driven forms cheatsheet (coming soon) Next lesson → File uploads and progress (coming soon)

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *