Angular Reactive Typed Forms — Not Just a Dream
This article will focus on Angular 14 and the most significant update since Ivy, including Typed Reactive Forms and Standalone Components plus minor improvements.
Join the DZone community and get the full member experience.
Join For FreeWhen the new Angular version 14 was released, I was quite satisfied with two new features, and I wanted to share them with you. The first is Typed Reactive Forms, and the second is Standalone Components.
After 6 years of the first release and after months of discussion and feedback, the most needed feature and up-voted issue in the Angular repository are now solved in Angular v14!
Angular 14 was released 2nd of June with the most significant update since Ivy. It includes two long-awaited features, Typed Reactive Forms, and Standalone Components, as well as several minor improvements.
In this article, we will focus on Typed Reactive Forms. As before Angular v14, Reactive Forms did not include type definitions in many of its classes, and TypeScript would not catch bugs like in the following example during compilation.
const loginForm = new FormGroup({
email: new FormControl(''),
password: new FormControl(''),
});
console.log(login.value.username);
With Angular 14, the FormGroup, formControl, and related classes include type definitions enabling TypeScript to catch many common errors.
Migration to the new Typed Reactive Forms is not automatic.
The already existing code containing FormControls, FormGroups, etc.. will be prefixed as Untyped during the upgrade. It is important to mention that if developers would like to take advantage of the new Typed Reactive Forms, must manually remove the Untyped prefix and fix any errors that may arise.
More details about this migration can be found at the official Typed Reactive Forms documentation.
A Step-By-Step Migration Example of an Untyped Reactive Form
Let's say that we have the following register form.
export class RegisterComponent {
registerForm: FormGroup;
constructor() {
this.registerForm = new FormGroup({
login: new FormControl(null, Validators.required),
passwordGroup: new FormGroup({
password: new FormControl('', Validators.required),
confirm: new FormControl('', Validators.required)
}),
rememberMe: new FormControl(false, Validators.required)
});
}
}
Angular also provided an automated migration to speed up the process. This migration will run when we, as developers, run the following command.
ng update @angular/core
or on demand if we already manually updated your project by running the next command. ng update @angular/core --migrate-only=migration-v14-typed-forms
.
In our example, if we use the automated migration, we end up with the above-changed code.
export class RegisterComponent {
registerForm: UntypedFormGroup;
constructor() {
this.registerForm = new UntypedFormGroup({
login: new UntypedFormControl(null, Validators.required),
passwordGroup: new UntypedFormGroup({
password: new UntypedFormControl('', Validators.required),
confirm: new UntypedFormControl('', Validators.required)
}),
rememberMe: new UntypedFormControl(false, Validators.required)
});
}
}
The next step now is to remove all the Untyped* usage and adjust properly our form.
Each UntypedFormControl must be converted to FormControl<T>, with T the type of the value of the form control. Most of the time, TypeScript can infer this information based on the initial value given to the FormControl.
For example, passwordGroup can be converted easily:
passwordGroup: new FormGroup({
password: new FormControl('', Validators.required), // inferred as `FormControl<string | null>`
confirm: new FormControl('', Validators.required) // inferred as `FormControl<string | null>`
}),
Note that the inferred type is string | null and not string. This is because calling .reset() on a control without specifying a reset value resets the value to null. This behavior is here since the beginning of Angular, so the inferred type reflects it. We’ll come back to this possibly null value in an example below, as it can be annoying (but there is always a way).
Now let’s take the field registerForm. Unlike FormControl, the generic type expected by FormGroup is not the type of its value but a description of its structure in terms of form controls:
registerForm: FormGroup<{
login: FormControl<string | null>;
passwordGroup: FormGroup<{
password: FormControl<string | null>;
confirm: FormControl<string | null>;
}>;
rememberMe: FormControl<boolean | null>;
}>;
constructor() {
this.registerForm = new FormGroup({
login: new FormControl<string | null>(null, Validators.required),
passwordGroup: new FormGroup({
password: new FormControl('', Validators.required),
confirm: new FormControl('', Validators.required)
}),
rememberMe: new FormControl<boolean | null>(false, Validators.required)
});
}
Nullability in Forms
As we can see above, the types of the controls are string | null and boolean | null, and not string and boolean as we could expect. This is happening because if we call the .reset() method on a field, resets its value to null. Except if we give a value to reset, for example .reset(''), but as TypeScript doesn’t know if and how you are going to call .reset(), the inferred type is nullable.
We can tweak behavior by passing the nonNullable options (which replaces the new option introduced in Angular v13.2 initialValueIsDefault). With this option, we can get rid of the null value if we want to!
On one hand, this is very handy if your application uses strictNullChecks, but on the other hand, this is quite verbose, as we currently have to set this option on every field (I hope this changes in the future).
registerForm = new FormGroup({
login: new FormControl<string>('', { validators: Validators.required, nonNullable: true }),
passwordGroup: new FormGroup({
password: new FormControl('', { validators: Validators.required, nonNullable: true }),
confirm: new FormControl('', { validators: Validators.required, nonNullable: true })
}),
rememberMe: new FormControl<boolean>(false, { validators: Validators.required, nonNullable: true })
}); // incredibly verbose version, that yields non-nullable types
Another way to achieve the same result is to use the NonNullableFormBuilder. A new property introduced by Angular v14 called nonNullable that returns a NonNullableFormBuilder, which contains the usual as known control, group, array, etc., methods to build non-nullable controls.
Example of creating a non-nullable form group:
constructor(private fb: NonNullableFormBuilder) {}
registerForm = this.fb.group({
login: ['', Validators.required]
});
So, Does This Migration Worth It? What Do We Gain With Typed Reactive Forms?
Before Angular v14, the existing forms API does note performing very well with TypeScript because every form control value is typed as any. So, we could easily write something like this.registerForm.value.something,
and the application would compile successfully.
This is no longer the case: the new forms API properly types value according to the types of the form controls. In my example above (with nonNullable), the type of this.registerForm.value is:
// this.registerForm.value
{
login?: string;
passwordGroup?: {
password?: string;
confirm?: string;
};
rememberMe?: boolean;
}
We can spot some ? in the type of the form value. What does it mean?
It is widely known that in Angular, we can disable any part of our form we want; if so, Angular will automatically remove the value of a disabled control from the value of the form.
this.registerForm.get('passwordGroup').disable();
console.log(this.registerForm.value); // logs '{ login: null, rememberMe: false }'
The above result is a bit strange, but it explains sufficiently why the fields are marked as optional if they have been disabled. So, they are not part of the this.registerForm.value anymore. TypeScript calls this feature a Partial value.
There is also a way to get the hole object even with the disabled fields by running the .getRawValue() function on the form.
{
login: string;
passwordGroup: {
password: string;
confirm: string;
};
rememberMe: boolean;
} // this.registerForm.getRawValue()
Event More Strictly Typed .get() Function
The get(key) method is also more strictly typed. This is great news, as we could previously call it with a key that did not exist, and the compiler would not see the issue.
Thanks to some hardcore TypeScript magic, the key is now checked, and the returned control is properly typed! It is also works with array syntax for the key as below.
his.registerForm.get('login') // AbstractControl<string> | null
this.registerForm.get('passwordGroup.password') // AbstractControl<string> | null
//Array Syntax
this.registerForm.get(['passwordGroup', '.password'] as const) // AbstractControl<string> | null
Also works with nested form arrays and groups, and if we use a key that does not exist, we can finally get an error:
this.registerForm.get('hobbies.0.name') // AbstractControl<string> | null
//Non existing key
this.registerForm.get('logon' /* typo */)!.setValue('cedric'); // does not compile
As you can see, get() returns a potentially null value: this is because you have no guarantee that the control exists at runtime, so you have to check its existence or use! Like above.
Note that the keys you use in your templates for formControlName, formGroupName, and formArrayName aren’t checked, so you can still have undetected issues in your templates.
Something Fresh: FormRecord
FormRecord is a new form entity that has been added to the API. A FormRecord is similar to a FormGroup, but the controls must all be of the same type. This can help if you use a FormGroup as a map, to which you add and remove controls dynamically. In that case, properly typing the FormGroup is not really easy, and that’s where FormRecord can help.
It can be handy when you want to represent a list of checkboxes, for example, where your user can add or remove options. For example, our users can add and remove the language they understand (or don’t understand) when they register:
languages: new FormRecord({
english: new FormControl(true, { nonNullable: true }),
french: new FormControl(false, { nonNullable: true })
});
// later
this.registerForm.get('languages').addControl('spanish', new FormControl(false, { nonNullable: true }));
If we try to add a control of a different type, TS throws a compilation error!
But as the keys can be any string, there is no type-checking on the key in removeControl(key) or setControl(key). Whereas if you use a FormGroup, with well-defined keys, you do have type checking on these methods: setControl only allows a known key, and removeControl only allows a key marked as optional (with a ? in its type definition).
If we have a FormGroup on which we want to add and remove control dynamically, we’re probably looking for the new FormRecord type.
Conclusion
I’m very excited to see this new form of API in Angular! This is, by far, one of the biggest changes in recent years for developers. Ivy was big but didn’t need us to make a lot of changes in our applications. Typed forms are another story: the migration is likely to impact dozens, hundreds, or thousands of files in our applications!
The TypeScript support in Angular has always been outstanding but had a major blind spot with forms: this is no longer the case!
So, yes. It is totally worth it!!
Till next time,
Happy coding.
Published at DZone with permission of Anastasios Theodosiou. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments