Introduction
In an earlier post, I examined standalone components and how they behave. Angular 14, though, ships several other major features, among them strictly typed reactive forms — so the need for awkward workarounds is finally gone: the framework now provides built-in form typing.
How does it work?
How do you adopt it in your projects?
What does the Angular 14 migration schematics actually do?
FormControl
The FormControl class now accepts a generic type parameter, letting you type the control itself, its value, and the return types of its methods.
const nameControl = new FormControl<string | null>('');
nameControl(23); // Type Error
const name = nameControl.value // string | null
Why can the type be null?
The control's type may be null because of the reset method — by default, it resets the field to null. If that behavior is not what you want, a new option, nonNullable, is available. This option replaces initialValueIsDefault, which is now deprecated.
const nameControl = new FormControl('', { nonNullable: true });
In the example above, the typing is implicit rather than explicit. Angular automatically infers that the value is of type string and non-null.
FormGroup
Like FormControl, FormGroup also takes a generic — it can be inferred implicitly or stated explicitly.
const addressControl = new FormGroup({
street: new FormControl('', { nonNullable: true }),
city: new FormControl('', { nonNullable: true }),
});
const street: addressControl.value.street // string|undefined
Why can the type of the street variable be undefined?
When a FormGroup is disabled, the value property only includes values from controls that are not disabled. By definition, disabled controls therefore contribute no value. A simple way around this is to use getRawValue, which returns all form values regardless of the form's state.
In practice, forms can get large — with controls added or removed at runtime. In such cases, explicit typing becomes especially valuable and intuitive.
interface PersonForm {
firstname: FormControl<string>;
lastname: FormControl<string>;
username?: FormControl<string | null>
}
const personForm = new FormGroup<PersonForm>({
firstname: new FormControl('', { nonNullable: true }),
lastname: new FormControl('', { nonNullable: true }),
username: new FormControl(null),
});
personForm.removeControl('firstname'); // error: firstname required
personForm.removeControl('username'); // no error
FormRecord
Typing brings many benefits but also a few small drawbacks. A simple example: how do you add controls to an existing form at runtime without knowing their keys in advance? With strict typing on FormGroup, this kind of task can become awkward.
Angular introduces a new API to address this: FormRecord.
const languages = new FormRecord({
french: new FormControl(false, { nonNullable: true }),
english: new FormControl(false, { nonNullable: true })
});
languages.addControl('italian', new FormControl(0, { nonNullable: true }); // error
languages.addControl('italian', new FormControl(false, { nonNullable: true }); // no error
FormRecord allows you to add controls dynamically, as long as their values share the same type. Unlike its counterpart FormGroup, the setValue and removeControl methods on FormRecord perform no type checking. This API is handy for representing a set of checkboxes, for example.
FormArray
FormArray also gets a small update so it can be typed generically.
const names = new FormArray([new FormControl('', { nonNullable: true })])
Controls inside the FormArray are then typed as FormControl instances. As before, explicit typing is available when inference falls short.
const names = new FormArray<FormControl<string>>([new FormControl('', { nonNullable: true })])
FormBuilder
Just like the classes above, FormBuilder has been updated to support typing. On top of that, a new NonNullableFormBuilder injection is available to avoid the boilerplate that comes with the nonNullable option.
@Component({
selector: 'app-form',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
addressForm = this.fb.group({
street: '',
city: ''
});
constructor(private readonly fb: NonNullableFormBuilder) {}
}
This is the cleanest and recommended approach. Still, an alternative exists using the nonNullable property.
@Component({
selector: 'app-form',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
addressForm = this.fb.nonNullable.group({
street: '',
city: ''
});
constructor(private readonly fb: FormBuilder) {}
}
Running the Migration
When you move your project to Angular 14, the provided schematics will carry out a series of small adjustments inside your application. These changes convert every instance of FormGroup, FormControl, and FormArray into their corresponding 'UnTyped' versions.
The exact replacements are listed below:
FormControl → UnTypedFormControl
FormGroup → UnTypedFormGroup
FormArray → UnTypedFormArray
Note: UnTypedFormControl is technically nothing more than an alias for the existing FormControl type.
Reminder: This migration process is triggered automatically when you run the standard update command:
ng update @angular/core
Alternatively, if you have manually handled your dependency upgrades, you can invoke the same migration on demand using:
ng update @angular/core --migrate-only=migration-v14-typed-forms
A Handy Tip to Preserve Your Work
For those who have already invested time in typing their controls or form structures using simple interfaces, there is no need to discard that effort. By introducing a generic type, you can retain all of your existing type definitions with relative ease.
interface Person {
name: string;
username: string;
}
type ControlsFromInterface<T extends Record<string, any> = {
[key in keyof T]: T[key] extends Record<any, any>
? FormGroup<ControlsFromInterface<T[key]>>
: FormControl<T[key]>
};
const personForm = new FormGroup<ControlsFromInterface<Person>>({
name: new FormControl('', { nonNullable: true }),
username: new FormControl('', { nonNullable: true })
});
