1. FormControl, FormGroup & FormArray (Typed Forms)
Three building blocks, composable into any shape: a single value
(FormControl), a fixed set of named controls (FormGroup), and
a variable-length list of controls (FormArray). Since Angular 14, forms are
strictly typed by default — no more any leaking through
your form values.
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-signup-form',
templateUrl: './signup-form.html',
imports: [ReactiveFormsModule],
})
export class SignupForm {
form = new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
password: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(8)] }),
});
onSubmit() {
if (this.form.invalid) return;
console.log(this.form.value); // typed as { email: string; password: string }
}
}
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" type="email" />
<input formControlName="password" type="password" />
<button type="submit" [disabled]="form.invalid">Sign up</button>
</form>
nonNullable: true is what makes form.value.email typed as
string instead of string | null — worth setting explicitly on
every control unless you genuinely want null to be a valid state (a control
that can be reset to empty, for instance).
2. Built-in & Custom Synchronous Validators
Validators.required, .email, .minLength(), and a
handful of others cover common cases. A custom validator is just a function matching a
specific signature: given a control, return an error object or null.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function strongPassword(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value as string;
const hasNumber = /\d/.test(value);
const hasUpper = /[A-Z]/.test(value);
if (value && hasNumber && hasUpper) {
return null; // valid
}
return { strongPassword: { hasNumber, hasUpper } };
};
}
password: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(8), strongPassword()],
}),
The returned error object's shape is entirely up to you — { strongPassword: {...}
} here — and it's exactly what you read back out in the template to show a
specific message: form.controls.password.errors?.['strongPassword'].
3. Async Validators & Debounced Validation
An async validator returns an Observable or Promise instead of a value directly — for checks that need a server round-trip, like confirming a username isn't already taken.
import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { UsersService } from './users.service';
import { map, debounceTime, switchMap, first } from 'rxjs';
export function usernameTaken(): AsyncValidatorFn {
const users = inject(UsersService);
return (control: AbstractControl) =>
control.valueChanges.pipe(
debounceTime(400),
switchMap((value) => users.checkAvailable(value)),
map((available) => (available ? null : { usernameTaken: true })),
first()
);
}
Angular automatically marks the control as PENDING while an async validator
is running, which you can check in the template to show a "checking…" state — worth
doing, since the debounce alone means there's a real, user-visible delay before the
result comes back.
4. Cross-Field Validation Patterns
Some validation needs more than one field at once — confirming two password fields match, for instance. That validator belongs on the group, not either individual control, since it needs to read both.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function passwordsMatch(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordsMismatch: true };
};
}
form = new FormGroup(
{
password: new FormControl('', { nonNullable: true }),
confirmPassword: new FormControl('', { nonNullable: true }),
},
{ validators: [passwordsMatch()] }
);
The resulting error lives on form.errors (the group), not on either
individual control — a common source of confusion the first time you look for it in the
wrong place.
5. Dynamically Adding/Removing Form Controls
FormArray holds a variable-length list of controls — exactly what you need
for an "add another" pattern like multiple phone numbers or emergency contacts.
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
export class ContactForm {
form = new FormGroup({
name: new FormControl('', { nonNullable: true }),
phoneNumbers: new FormArray([
new FormControl('', { nonNullable: true, validators: [Validators.required] }),
]),
});
get phoneNumbers() {
return this.form.controls.phoneNumbers;
}
addPhoneNumber() {
this.phoneNumbers.push(new FormControl('', { nonNullable: true, validators: [Validators.required] }));
}
removePhoneNumber(index: number) {
this.phoneNumbers.removeAt(index);
}
}
<div formArrayName="phoneNumbers">
@for (control of phoneNumbers.controls; track $index) {
<input [formControlName]="$index" />
<button type="button" (click)="removePhoneNumber($index)">Remove</button>
}
</div>
<button type="button" (click)="addPhoneNumber()">Add another</button>
Notice track $index here, not a stable ID — the array holds interchangeable
text inputs with no meaningful identity of their own, exactly the case from Week 6 where
index-based tracking is the right call.
6. Form State & UX Patterns
Every control tracks more than just its value — dirty (changed by the
user), touched (blurred at least once), and pending (an async
validator is running) all drive real UX decisions.
@if (form.controls.email.invalid && form.controls.email.touched) {
<p class="error">Enter a valid email address.</p>
}
Gating the error on touched, not just invalid, is the whole
trick — without it, every field shows an error the instant the form loads, before the
user has even typed anything. This one pattern is responsible for more form-UX
improvement than almost anything else in this lesson.
7. Hands-on Exercise
Build a multi-step signup form
Combine every technique from this lesson into one realistic form.
Requirements:
- Step 1:
email(required, valid format) and a fake asyncusernameTakenvalidator on ausernamefield, showing a "checking availability…" state while pending. - Step 2:
passwordandconfirmPassword, with the group-levelpasswordsMatchvalidator, plus thestrongPasswordcustom validator onpassworditself. - Step 3: a
FormArrayof "skills" (text inputs) with add/remove buttons, requiring at least one entry. - A "Next" button per step disabled until that step's controls are valid; only mark controls as touched when the user tries to advance, so errors don't appear prematurely.
- A final review step showing the full typed
form.value, and a submit handler that logs it.
To mark every control in a step touched at once (rather than one at a time), call .markAllAsTouched() on that step's FormGroup when the user clicks "Next" while it's still invalid.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does setting nonNullable: true on a FormControl actually change?
What does setting nonNullable: true on a FormControl actually change?
It changes the control's TypeScript type so its value is typed as, e.g., string instead of string | null — and it changes runtime behavior too: calling .reset() on the control resets it to its initial value rather than null. Without it, every read of the value needs a null check even when you never intend the field to actually be empty.
Q2
Why does a cross-field validator like passwordsMatch go on the FormGroup instead of the confirmPassword control?
Why does a cross-field validator like passwordsMatch go on the FormGroup instead of the confirmPassword control?
A validator on a single control only receives that control's own value — it has no way to read a sibling control's value. The group is the lowest level that has access to both password and confirmPassword at once, so that's where a validator comparing them has to live.
Q3
Why gate an error message on control.touched in addition to control.invalid?
Why gate an error message on control.touched in addition to control.invalid?
An empty required field is invalid from the moment the form loads — checking only invalid would show every error immediately, before the user has interacted with anything. touched only becomes true once the control has been focused and blurred at least once, so gating on both means errors only appear after the user has actually had a chance to fill the field in.
Q4
Why use track $index for the phone-numbers FormArray instead of tracking by some ID?
Why use track $index for the phone-numbers FormArray instead of tracking by some ID?
The controls in this array are interchangeable text inputs with no inherent identity of their own — there's no natural "ID" a phone-number input has independent of its position. Per Week 6's guidance, index-based tracking is the right call precisely when items genuinely lack a stable identity, which is exactly this case.