Angular Typed Reactive Forms: FormGroup, NonNullableFormBuilder, Strict Mode [2026]
Before v14, Reactive Forms had a typing problem: FormGroup.value was any, FormControl.value was loosely typed, and setValue and patchValue accepted any shape. v14 fixed it with strictly-typed FormGroup<T>. v15 added NonNullableFormBuilder. By v17 the typed APIs are the only ones the Angular team recommends — but plenty of legacy code still uses the untyped versions. This lesson is the migration guide.
This is lesson 4.6, closing Module 4. After this, every forms era is in your toolkit and you have a clear migration path for any legacy codebase.
The two big improvements #
Reactive Forms in 2026 come in three typing flavors:
| Flavor | API surface | Typing |
|---|---|---|
| Untyped (legacy) | FormGroup / UntypedFormControl |
value is any, errors loose |
| Typed | FormGroup<T> / FormControl<T | null> |
Strictly typed but values are nullable |
| NonNullable | NonNullableFormBuilder.group({...}) |
Strictly typed AND never null |
For new code: use NonNullable. For legacy migration: typed is a stepping stone.
Typed FormGroup<T> — the modern shape #
The FormBuilder.group({...}) method infers the controls' types from the initial values:
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `...`,
})
export class LoginComponent {
private fb = inject(FormBuilder);
loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit() {
const v = this.loginForm.value;
// ^? { email?: string | null; password?: string | null }
}
}
Three things to note:
valueproperties are typed based on the initial-value types- Each is optional and nullable —
email?: string | null— because controls can be disabled (undefinedinvalue) and any control can technically holdnull getRawValue()returns the same shape but disabled fields are included (still nullable)
This is a real improvement over the legacy value: any, but the optional + nullable shape is still verbose to consume. That's where NonNullable comes in.
NonNullableFormBuilder — the cleanest shape #
NonNullableFormBuilder.group({...}) builds controls that:
- Are never
null(initialized to the initial value, reset to it) - Are never
undefinedinvalue(no disable-fields-disappear-from-value issue)
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, Validators } from '@angular/forms';
export class LoginComponent {
private fb = inject(NonNullableFormBuilder);
loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit() {
const v = this.loginForm.getRawValue();
// ^? { email: string; password: string } — clean, no | null, no ?
console.log(v.email); // safe to use directly
}
}
The non-nullable shape is what you reach for in 2026 reactive code. It is the closest reactive-forms can get to signal-forms' type ergonomics.
To get the non-nullable builder from a regular FormBuilder:
private fb = inject(FormBuilder).nonNullable;
Per-control non-nullability #
For mixed forms, individual controls can opt-in:
this.fb.group({
email: ['', { nonNullable: true, validators: [Validators.required] }],
birthday: [null as Date | null, { validators: [] }], // explicit nullable
});
The email control is non-nullable; birthday keeps the legacy nullable behavior. Useful when one field genuinely accepts null ("clear date") and others don't.
Explicit FormControl<T> types #
When you build controls directly (no FormBuilder), specify the type generic:
import { FormControl, FormGroup, Validators } from '@angular/forms';
// Typed FormControl (nullable)
email = new FormControl<string | null>('', { validators: [Validators.required] });
// Non-nullable
userId = new FormControl<number>(0, { nonNullable: true });
// In a group
login = new FormGroup({
email: new FormControl<string>('', { nonNullable: true }),
password: new FormControl<string>('', { nonNullable: true }),
});
Generic inference works in most cases; the explicit <T> is for unusual cases (a control that holds a complex object).
getRawValue() vs value #
this.form.value; // disabled controls excluded — type is { email?: string }
this.form.getRawValue(); // ALL controls — type is { email: string }
For non-nullable forms with no disabled fields, both return the same thing. For forms where you toggle controls' disabled state, getRawValue() is the cleaner consumer because the type matches the form's full shape.
Strict patches and sets #
The setValue and patchValue methods now type-check the input:
this.loginForm.setValue({ email: 'a@b.c', password: 'pw' }); // ✓ ok
this.loginForm.setValue({ email: 'a@b.c' }); // ❌ password missing
this.loginForm.setValue({ email: 'a@b.c', password: 'pw', extra: 1 }); // ❌ extra not in shape
this.loginForm.patchValue({ email: 'new@b.c' }); // ✓ partial OK
this.loginForm.patchValue({ unknown: 1 }); // ❌ key not in shape
No more silently-ignored typos or shape mismatches.
Nested groups and arrays — typed #
Groups within groups:
address = this.fb.group({
line1: ['', Validators.required],
city: ['', Validators.required],
zip: ['', Validators.pattern(/^\d{5}$/)],
});
user = this.fb.group({
name: ['', Validators.required],
address: this.address, // nested
});
this.user.value;
// ^? { name?: string | null; address?: { line1?: string | null; city?: string | null; zip?: string | null } | null }
Arrays of typed controls:
import { FormArray } from '@angular/forms';
todos = new FormArray<FormGroup<{
title: FormControl<string>;
done: FormControl<boolean>;
}>>([]);
addTodo(title: string) {
this.todos.push(this.fb.group({
title: [title, { nonNullable: true, validators: [Validators.required] }],
done: [false, { nonNullable: true }],
}));
}
The FormArray<T> generic lets you describe the shape of each item, so .value is typed correctly.
Migrating from untyped to non-nullable #
Three mechanical steps for an existing form:
// Before (untyped)
login = new UntypedFormGroup({
email: new UntypedFormControl('', [Validators.required]),
password: new UntypedFormControl(''),
});
// Step 1: Rename to typed equivalents (FormGroup / FormControl)
login = new FormGroup({
email: new FormControl('', [Validators.required]),
password: new FormControl(''),
});
// Now nullable — value is { email?: string|null; password?: string|null }
// Step 2: Switch FormBuilder usage
private fb = inject(FormBuilder);
login = this.fb.group({
email: ['', [Validators.required]],
password: [''],
});
// Step 3: Switch to NonNullableFormBuilder
private fb = inject(FormBuilder).nonNullable;
login = this.fb.group({
email: ['', [Validators.required]],
password: [''],
});
// Now value is { email: string; password: string } — done
The migration is mechanical. Most teams use a regex search-and-replace for steps 1 and 2; step 3 is a one-line change per form.
When typed reactive is preferred over signal forms #
For most cases in 2026, signal forms (lesson 4.1) are the cleaner choice. Typed reactive remains preferable when:
- You're maintaining a large reactive-forms codebase and migration cost matters
- A third-party library (Material's MatStepper, ngx-formly, jsonforms-angular) requires reactive forms
- You need the rich RxJS-based observers (
valueChanges,statusChanges) for complex stream orchestration - Your team has deep reactive-forms expertise and signal-forms onboarding is non-trivial
For new greenfield code with no dependencies pushing one way: signal forms.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
value.email is string | null | undefined even with NonNullableFormBuilder |
Reading .value instead of .getRawValue() (and a control is disabled somewhere) |
Use getRawValue() for the full shape |
setValue({ a: 1 }) rejects |
Missing fields | setValue requires the full shape; patchValue accepts partial |
| Reset clears values even though they're non-nullable | reset() resets to the initial values (or null if not provided) |
Pass initial values to reset({...}) or use nonNullable: true (resets to the original initial) |
| Migration in steps breaks at step 1 | Mixed Untyped/Typed in nested groups | Migrate top-down (outermost group first), then inner controls |
| FormArray generic missing | TypeScript can't infer the FormArray item shape from push |
Provide the explicit generic: new FormArray<FormGroup<...>>(...) |
What's next #
Module 4 closes here. Module 5 picks up routing — starting with router essentials (5.1), complete route configuration (5.2), functional route guards (5.3), router events (5.4), data resolvers (5.5), and lazy loading with @defer (5.6).
With forms behind you, every form pattern you write in the rest of the tutorial uses the modern APIs by default.
Try it yourself #
A non-nullable typed reactive form for a settings page:
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-settings',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<input formControlName="displayName" />
<input formControlName="email" type="email" />
<input formControlName="notifications" type="checkbox" />
<button type="submit" [disabled]="form.invalid">Save</button>
</form>
`,
})
export class SettingsComponent {
private fb = inject(FormBuilder).nonNullable;
form = this.fb.group({
displayName: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
notifications: [true],
});
save() {
if (this.form.valid) {
const v = this.form.getRawValue();
// ^? { displayName: string; email: string; notifications: boolean }
console.log('Saving:', v);
}
}
}
No | null, no ?, no value as any. Strict typing all the way through.
.nonNullable to your FormBuilder) and immediately gives you type safety. Jumping straight to signal forms means rewriting templates too (every formControlName becomes [value]/(input)) and migrating 200 controls in one push is risky. Phase it: typed reactive first (low effort, big type-safety win), then opportunistic signal-forms migration when you next touch each form file.Module 4 is complete. Module 5 picks up routing.
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.


