Typed Reactive Forms
Before Angular 14, every FormControl was effectively typed as any, which meant form.value returned untyped data and typos in control names slipped past the compiler. Typed reactive forms close that gap: FormControl, FormGroup, FormArray, and FormBuilder now carry precise generic types inferred from your initial values. The result is end-to-end type safety from declaration to value, valueChanges, and get(), with autocompletion in the editor and errors at compile time instead of runtime.
How typing is inferred
When you create a control with an initial value, Angular infers the control type from that value. A FormGroup aggregates the types of its child controls into a strongly-typed value object.
import { Component, inject } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-profile',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<input formControlName="name" />
<input formControlName="age" type="number" />
<button type="submit">Save</button>
</form>
`,
})
export class ProfileComponent {
form = new FormGroup({
name: new FormControl('', { nonNullable: true }),
age: new FormControl(0, { nonNullable: true, validators: [Validators.min(0)] }),
});
save() {
const value = this.form.value; // Partial<{ name: string; age: number }>
console.log(value.name?.toUpperCase());
}
}
Here this.form.value has the type Partial<{ name: string; age: number }>. Accessing a control that does not exist, or calling a string method on age, is now a compile error.
Nullable vs non-nullable controls
By default a FormControl is nullable because calling reset() without an argument sets the value back to null. That is why value.name above is string | undefined within the partial. To get a plain non-nullable type, pass nonNullable: true, which also makes reset() restore the initial value instead of null.
const nullable = new FormControl('hello'); // FormControl<string | null>
const strict = new FormControl('hello', { nonNullable: true }); // FormControl<string>
nullable.reset(); // value becomes null
strict.reset(); // value becomes 'hello' (the initial value)
| Option | Control type | reset() behaviour |
|---|---|---|
| default | FormControl<T | null> | resets to null |
{ nonNullable: true } | FormControl<T> | resets to initial value |
Prefer
nonNullable: truefor fields that should never benull. It removes noisy| nullunions from your value type and givesreset()more intuitive semantics.
Typed getRawValue, get, and valueChanges
value always returns a Partial because disabled controls are omitted. When you need every control regardless of disabled state, use getRawValue(), which returns the full, non-partial type.
const raw = this.form.getRawValue(); // { name: string; age: number }
The get() method is also typed against the known control names, and valueChanges emits the same partial value type, so RxJS pipelines stay type-safe.
import { map } from 'rxjs';
const ageControl = this.form.get('age'); // FormControl<number> | null
const upper$ = this.form.controls.name.valueChanges.pipe(
map((name) => name.toUpperCase()), // name is string, not any
);
Nested groups and typed arrays
Nesting works recursively. A child FormGroup contributes a nested object type, and FormArray infers the type of its element controls.
import { FormArray, FormControl, FormGroup } from '@angular/forms';
const form = new FormGroup({
name: new FormControl('', { nonNullable: true }),
address: new FormGroup({
city: new FormControl('', { nonNullable: true }),
zip: new FormControl('', { nonNullable: true }),
}),
tags: new FormArray<FormControl<string>>([]),
});
form.controls.tags.push(new FormControl('angular', { nonNullable: true }));
const city = form.controls.address.controls.city.value; // string
FormRecord for dynamic keys
FormGroup requires a fixed, known set of keys. When control names are dynamic (for example, one toggle per feature flag fetched at runtime), use FormRecord, a homogeneous group where keys are open but every control shares the same type.
import { FormControl, FormRecord } from '@angular/forms';
const flags = new FormRecord<FormControl<boolean>>({});
flags.addControl('darkMode', new FormControl(true, { nonNullable: true }));
flags.addControl('beta', new FormControl(false, { nonNullable: true }));
const value = flags.value; // { [key: string]: boolean }
Opting out with UntypedFormControl
If you are migrating a large codebase, Angular ships UntypedFormGroup, UntypedFormControl, UntypedFormArray, and UntypedFormBuilder that preserve the legacy any behaviour. Use them as a temporary bridge, then replace them incrementally.
import { UntypedFormControl } from '@angular/forms';
const legacy = new UntypedFormControl(''); // value is any
Output:
legacy.value -> any (no compile-time checking)
Treat
Untyped*classes as a migration aid only. Leaving them in place permanently discards the entire benefit of typed forms.
Best Practices
- Always provide an initial value so Angular can infer the control type instead of falling back to
any. - Use
{ nonNullable: true }for required fields to drop| nullfrom the value type and get sensiblereset()semantics. - Reach for
getRawValue()when you must read disabled controls; otherwise expect aPartialfromvalue. - Use
FormRecordfor dynamic, same-typed keys and reserveFormGroupfor a known, fixed shape. - Define a domain interface and let the form shape mirror it, so submission payloads stay aligned with your API contracts.
- Treat
UntypedFormControland friends as temporary migration scaffolding, not a permanent solution.