For most of reactive forms' life, form.value returned any. You could build a FormGroup with a dozen typed controls and the moment you called .value, TypeScript stopped helping you — a typo in a property name, a wrong assumption about nullability, none of it caught at compile time. Typed forms fixed this at the root: control types now flow through the entire form tree.
What Untyped Forms Actually Cost You
// Untyped: this compiles and fails at runtime
this.signupForm = new FormGroup({
email: new FormControl(''),
age: new FormControl(0),
});
const email = this.signupForm.value.emial; // typo — silently undefinedNothing in that example produces a compiler error. The bug surfaces in production, or in the best case, a manual test.
The Typed Version
import { FormControl, FormGroup, Validators } from '@angular/forms';
interface SignupForm {
email: FormControl<string>;
age: FormControl<number>;
}
const signupForm = new FormGroup<SignupForm>({
email: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
age: new FormControl(0, { nonNullable: true }),
});
const value = signupForm.value; // { email?: string; age?: number }
const email = signupForm.value.emial; // compile error: property doesn't existNote that signupForm.value types every field as optional — that's not a typing bug, it reflects that .value on a FormGroup only includes enabled controls, and a disabled control's key is genuinely absent. getRawValue() returns the fully required shape, including disabled controls, and is usually what you want when submitting.
nonNullable Replaces a Common Runtime Bug
Before typed forms, calling control.reset() set the value back to null even if the control started as a string — a classic source of "why is this a string sometimes and null other times" bugs downstream. nonNullable: true fixes the type to match reality: the control's value type excludes null, and reset() restores the initial value instead of nulling it out.
FormBuilder Infers the Same Way
Writing out FormGroup<T> interfaces by hand gets tedious for larger forms. The typed FormBuilder infers the shape from the initial values you pass it, so you rarely need to declare the interface explicitly:
@Component({ /* ... */ })
export class SignupComponent {
private readonly fb = inject(FormBuilder);
form = this.fb.nonNullable.group({
email: ['', Validators.required],
age: [0],
address: this.fb.group({
city: [''],
zip: [''],
}),
});
submit() {
const value = this.form.getRawValue();
// value.address.city is typed as string, nested groups included
}
}fb.nonNullable applies nonNullable: true to every control built through it, which is almost always what you want for form fields backed by primitive defaults.
Where the Type Safety Still Leaks
Typed forms infer control shape, not validation state. Validators.required doesn't narrow the type to a non-empty string — a required, non-nullable string control is still typed as string, empty string included, because TypeScript can't express "non-empty" as a distinct type from string. Validity has to be checked separately via form.valid before you trust the value for submission, same as before typed forms existed.
FormArray<T> and dynamically added controls are the other soft spot: adding a control at runtime with addControl still requires you to get the generic type right by hand, and untyped AbstractControl references (common in shared form utilities written before this feature existed) will silently degrade back to any if you're not careful about the types you pass across function boundaries.
Migrating an Existing Form
There's no automatic migration for typed forms the way there is for control flow — the compiler will simply start complaining once you upgrade, because untyped FormGroup/FormControl usages default to their old, more permissive types under the hood, but any new typed API you touch enforces the stricter contract. Convert form by form, starting with whichever forms have caused the most "value was unexpectedly null" bugs — that's where the payoff is immediate.