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 and returns either:

  • null — value is valid
  • an error description — value is invalid

Signal forms return { kind, message? } objects; reactive and template-driven return a { errorKey: payload } map. Same logic across all three:

// Signal forms (inside the schema function passed to form())
validate(s.password, ({ value }) => value().length >= 8 ? null : { kind: 'tooShort' });

// 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 UI checks error.kind === 'tooShort' (signal) or errors?.['tooShort'] (the others).

Built-in validators — coverage table #

Validator Use for Example
required() Field must have a non-empty value required(s.name)
email() Value matches a standard email shape email(s.email)
minLength() / maxLength() String/array length bounds minLength(s.password, 8)
min() / max() Numeric bounds min(s.age, 0); max(s.age, 120)
pattern() Regex match (anchor with ^...$ yourself) pattern(s.code, /^[A-Z0-9]+$/)

That's the lot (plus minDate() / maxDate() for dates). Anything else — phone numbers, credit cards, URLs, custom business rules — is a custom validator. Every built-in accepts { message, when } options.

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

validate() takes a path and a function:

import { form, required, validate } from '@angular/forms/signals';

signupForm = form(this.signupModel, (s) => {
  required(s.username);
  validate(s.username, ({ value }) =>
    /\s/.test(value()) ? { kind: 'hasSpaces', message: 'Username cannot contain spaces' } : null,
  );
});

Usage in the template (errors() is an array):

@for (error of form.username().errors(); track error) {
  <p>{{ error.message }}</p>
}

Cross-field validators (signal forms) #

The field context's valueOf() reads any other field by its path:

validate(s.confirmPassword, ({ value, valueOf }) =>
  value() === valueOf(s.password)
    ? null
    : { kind: 'mismatch', message: "Passwords don't match" },
);

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"), validate the root path so the error lands on the form:

model = signal<{ start: Date | null; end: Date | null }>({ start: null, end: null });

eventForm = form(this.model, (s) => {
  required(s.start);
  required(s.end);
  validate(s, ({ value }) => {
    const { start, end } = value();
    return start && end && end < start
      ? { kind: 'endBeforeStart', message: 'End must be after start' }
      : null;
  });
});

// Template
@for (error of eventForm().errors(); track error) { <p>{{ error.message }}</p> }

Async validators #

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

import { form, required, minLength, validateHttp } from '@angular/forms/signals';

signupForm = form(this.signupModel, (s) => {
  required(s.username);
  minLength(s.username, 3);
  validateHttp(s.username, {
    debounce: 300,
    request: ({ value }) => (value() ? `/api/users/check?name=${value()}` : undefined),
    onSuccess: (r: { taken: boolean }) =>
      r.taken ? { kind: 'taken', message: 'Username already taken' } : null,
    onError: () => ({ kind: 'serverError', message: 'Could not check username' }),
  });
});

Three behaviors:

  1. Debounced (opt-in) — debounce: 300 sends one request per pause
  2. Cancellable — typing while a request is in flight cancels it
  3. Pending state — field().pending() is true while running; it starts only after sync rules pass

In the template:

<input [formField]="form.username" />
@if (form.username().pending()) { <span>Checking...</span> }
@else if (form.username().invalid()) { <p>{{ form.username().errors()[0].message }}</p> }

validateHttp() needs provideHttpClient(); for non-HTTP checks use validateAsync().

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().invalid()) {
  <p>{{ form.email().errors()[0].message }}</p>
}

<!-- submitted(): your own signal -->
@if (submitted() && eventForm().errors().length) {
  <p>{{ eventForm().errors()[0].message }}</p>
}

Submitting marks every field touched, so touched-gated errors appear on submit. For a richer pattern, gate ALL error display on (touched && dirty):

@if (form.email().touched() && form.email().dirty() && form.email().invalid()) {
  <p>{{ form.email().errors()[0].message }}</p>
}

Composing validators #

Call several rules on one path. All of them run; every failure lands in .errors():

form(this.model, (s) => {
  required(s.email, { message: 'Email required' });
  email(s.email, { message: 'Invalid email' });
  validate(s.email, ({ value }) =>
    value() && !value().endsWith('@example.com') ? { kind: 'wrongDomain' } : null,
  );
});

// Empty: only the 'required' error
// 'bob@test': 'email' and 'wrongDomain' errors

Built-ins skip empty values (only required reports empty). Custom validators should do the same:

validate(s.x, ({ value }) => !value() ? null : /* check */)

Validator ordering and dependencies #

Rules run independently. If you need them to short-circuit (don't run pattern if required failed), guard with a conditional:

validate(s.code, ({ value }) => {
  if (!value()) return null;                   // skip if empty (required handles)
  return /^[A-Z]+$/.test(value()) ? null : { kind: 'pattern' };
});

Built-ins can use when instead. Rules 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, validate the root path:

credentialsForm = form(this.model, (s) => {
  required(s.email);
  email(s.email);
  minLength(s.password, 8);
  validateHttp(s, {
    request: ({ value }) => ({ url: '/api/check-credentials', method: 'POST', body: value() }),
    onSuccess: (r: { ok: boolean }) =>
      r.ok ? null : { kind: 'invalidCredentials', message: 'Invalid credentials' },
    onError: () => ({ kind: 'serverError', message: 'Could not verify' }),
  });
});

Fires once the whole form passes sync validation (add debounce to limit requests). 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
Error check reads undefined Treating errors like an object map Signal forms: .errors() is an array — check error.kind. Reactive: use errors?.['key']
Async validator fires on every keystroke No debounce configured Signal forms: debounce: 300 on validateHttp(); reactive forms: updateOn: 'blur' or a timer() inside the validator
Cross-field validator doesn't re-fire Read the dependent field outside the field context In signal forms, read via valueOf(s.x); 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, FormField, FormRoot, required, email, minLength, validate, validateHttp } from '@angular/forms/signals';

@Component({
  selector: 'app-signup',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="f">
      <input placeholder="Email" [formField]="f.email" />
      @if (f.email().touched() && f.email().invalid()) { <p>Invalid email</p> }

      <input placeholder="Username" [formField]="f.username" />
      @if (f.username().pending()) { <span>Checking...</span> }
      @else if (f.username().invalid()) { <p>{{ f.username().errors()[0].message }}</p> }

      <input type="password" placeholder="Password" [formField]="f.password" />
      <input type="password" placeholder="Confirm" [formField]="f.confirm" />
      @if (f.confirm().touched() && f.confirm().invalid()) { <p>Passwords don't match</p> }

      <button type="submit" [disabled]="f().invalid()">Create account</button>
    </form>
  `,
})
export class SignupComponent {
  model = signal({ email: '', username: '', password: '', confirm: '' });

  f = form(this.model, (s) => {
    required(s.email);
    email(s.email);
    minLength(s.username, 3, { message: 'At least 3 characters' });
    validateHttp(s.username, {
      debounce: 300,
      request: ({ value }) => `/api/check?u=${value()}`,
      onSuccess: (r: { taken: boolean }) => (r.taken ? { kind: 'taken', message: 'Already taken' } : null),
      onError: () => ({ kind: 'serverError', message: 'Check failed' }),
    });
    minLength(s.password, 8);
    validate(s.confirm, ({ value, valueOf }) => (value() === valueOf(s.password) ? null : { kind: 'mismatch' }));
  }, {
    submission: { action: async (field) => console.log('Signing up:', field().value()) },
  });
}

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

YouWhen should I show validation errors — on first render, on blur, only on submit?
Claude · used get_best_practicesThe 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.

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 *