Angular Form Validation Deep Dive: Built-in, Custom, Async, Cross-Field [2026]
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:
- Debounced — Angular collapses rapid changes; one server request per stable value
- Cancellable — typing while a request is in flight aborts it
- Pending state —
field.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.
Lesson 4.5 picks up file uploads with progress reporting.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


