Typed Forms in Angular
Angular version 14 is a feature-packed release that brings new APIs, functionality, and a developer experience. This includes one of the most requested feature improvements in Angular: typed forms.
In this article, you'll learn:
- How to migrate to the new strongly-typed forms API in Angular version 14
- Typed a
FormControl
- Typed a
FormGroup
- Typed a
FormArray
- New
FormRecord
- Mixing typed and untyped controls
- Non-nullable controls
Let's dive in!
Typed Forms API FTW 🎉
If you have been using Angular's forms API, whether template-based or reactive forms, you likely have noticed that prior to Angular 14 much of the API surface had a lot of any
types.
Angular (version 2, not AngularJS) was released in September of 2016 (after Flash died but before build tools took the world by storm). Not long after the release, developers started to note that the API was both not type-safe (or null-safe). In fact, the most upvoted and commented on issue in the GitHub repository was created December of 2016 to report this problem.
Angular 14 introduces reactive form APIs that are both type-safe and null-safe. This is a big win for using reactive forms in Angular. Unfortunately, the template-driven forms API is currently not strongly typed.
Migrating to Angular 14
To get started, you need to update your project to use Angular 14 to take advantage of the new APIs.
ng update @angular/cli @angular/core
Once your project is updated, you'll not that instance of FormControl
and FormGroup
have been migrated to the untyped classes:
FormControl
is migrated toUntypedFormControl
FormGroup
is migrated toUntypedFormGroup
Typed FormControl
In Angular 14, the FormControl
class now has a TypeScript generic type of TValue
whose default type assignment is any
. What does this mean?
Prior to Angular 14, when accessing properties such as value
the type was any
, and when using the method setValue
, it would accept any
argument value.
Here are just a few of the class members that have been updated to use the TValue
generic type:
setValue(value: TValue)
patchValue(value: TValue)
reset(formState: TValue)
getRawValue(): TValue
value: TValue
This is a big win for preventing runtime exceptions and regressions due to type errors!
It's important to note that the TValue
generic type is inferred by TypeScript when newing-up a new FormControl
instance, so it's not necessary to specify the generic type.
Let's look at an example.
const control = new FormControl('Mike');
Note, we did not specify the TValue
generic type above.
Rather, the type is inferred by the value
argument specified to constructor function when creating a new instance of the FormControl
class.
Typed FormGroup
The purpose of Angular's FormGroup
is to group form controls logically in order to track their state (value, validity, etc.) together.
Angular 14 also introduces updated typings for the FormGroup
API.
Let's look at an example.
interface SignUpForm {
name: string;
email: string;
subscribe?: string;
}
const signUpFormGroup = new FormGroup<SignUpForm>({
name: new FormControl(''),
email: new FormControl('')
});
Let's quickly review the code above:
- First, we define a new
SignUpForm
interface. - The interface is only necessary if we have optional form controls. In this example, the
subscribe
control can be dynamically added and removed from the group. - Next, we create a new
FormGroup
instance, and since we have an optional control, we specify the generic type to theSignUpForm
interface. - The
name
control's value is typed:string | null
- The
email
control's value is typed:string | null
- The
subscribe
control value is typed:string | null | undefined
- The
signUpFormGroup
value is typed:Partial<{ name: string | null; email: string | null; subscribe: string | null | undefined }>
You may have noticed that the control values can be null
or, if optional, undefined
.
This is because Angular's form APIs allow for null values.
For example, if we invoke the reset()
method on a control, the value is not set to the original/seed value, rather, it is set to null
.
We'll show how to override this behavior below.
It should also be noted that the TValue
for each control is also inferred as we previously learned.
Typed FormArray
Currently, TypeScript arrays are homogeneous.
This means that TypeScript expects that every item in an array is of the same type (including any
).
For the sake of clarity, this is not to be confused with tuples which can have unambiguous types.
With that said, typed FormArray
instances require that all controls are homogeneously typed.
If a FormArray
requires heterogeneous types then the use of the UntypedFormArray
is recommended.
In this example we'll create a new FormArray
with an initial control.
lineItems = new FormArray([
new FormControl('')
]);
Because we have defined the FormArray
to include a control whose TValue
is a string
, the type of the array is inferred as: FormArray<AbstractControl<string, string>>
.
The compiler now expects that each item in the array is a control whose TValue
is a string
.
If we attempt to add a new control to the array whose TValue
is not a string, the compiler will throw an exception indicating:
lineItems.push(new FormControl(0));
The compiler exception indicates that there is an error.
Argument of type 'FormControl<number>' is not assignable to parameter of type 'AbstractControl<string, string>'.
Types of property 'value' are incompatible.
Type 'number' is not assignable to type 'string'.
This is a good thing! 😌
Ok, but what if our array of controls is initially empty? In that case, we can specify the generic type of the controls:
lineItems = new FormArray<AbstractControl<string>>([]);
In the example above, we use the AbstractControl
type and specify the TValue
to be of type string
as we expect every control's value to be a string
.
New FormRecord
As we learned previously the FormGroup
in Angular 14+ supports specifying a group of controls whose TValue
types are known when creating the group, even when creating optional controls within the group.
However, what about a group of controls whose TValue
is not known when creating the group.
To meet this use case, Angular 14 ships with a new FormRecord
class.
Let's look at an example.
formGroup = new FormRecord<AbstractControl<string>>({});
Now that we have created the group of controls using the FormRecord
class, we can start to add (and/or remove) controls from the group.
formGroup.addControl('street', new FormControl(''));
This code works as expected.
A new control is added to the group that meets the type definition.
However, what happens if we attempt to add a new control whose TValue
is a number
?
this.formGroup.addControl('no', new FormControl(0));
Again, the compiler throws an exception indicating that we cannot add this control to the group due to the type constraints.
Argument of type 'FormControl<number>' is not assignable to parameter of type 'AbstractControl<string, string>'.
Types of property 'value' are incompatible.
Type 'number' is not assignable to type 'string'.
The UntypedFormGroup
class supports the user requirements in the event that we need a group of controls whose values are heterogeneous.
Mixed Types
We can declare mixed types of controls when working with a group of controls of heterogeneous types as long as we declare the controls when creating the group.
Further, we can use the UntypedFormControl
class to declare a control whose TValue
type is any
(under the hood, this is a type whose generic type is preset to any
).
Let's look at an example.
export class AppComponent implements OnInit {
formGroup = new FormGroup({
street: new FormControl(''),
no: new FormControl(0),
postalCode: new UntypedFormControl()
});
ngOnInit(): void {
const street = this.formGroup.value.street;
const no = this.formGroup.get('no').value;
this.formGroup.get('postalCode').setValue(12345);
this.formGroup.get('postalCode').setValue('ABC123');
}
}
This all works as we expect.
We have a few controls, each with their own TValue
, including the untyped control whose TValue
is any
.
Non-nullable Controls
If you recall from earlier, we mentioned that resetting a form control state will set the value to null
, not the initial/seed value as we might expect.
With non-nullable form controls, we can explicitly instruct the compiler that the value is reset to the initial value when invoking the reset()
method.
Let's look at an example
export class AppComponent implements OnInit {
formGroup = new FormGroup({
street: new FormControl('', { nonNullable: true })
});
ngOnInit(): void {
this.formGroup.get('street').reset();
const street = this.formGroup.value.street;
console.log(street); // empty string, NOT null
}
}
Key Takeaways
There are a few key takeaways when using Angular 14's updated forms API:
- The
FormControl
,FormGroup
, andFormArray
signatures have been updated to support TypeScript generics. - The
TValue
type is inferred where possible. FormGroup
supports enumerated, homogeneous, and optional controls.FormRecord
supports non-enumerated (dynamic) and homogenous controls.UntypedFormControl
is a non strongly-typedFormControl
. Or, in other words, theTValue
type is preset toany
.UntypedFormGroup
is a non strongly-typedFormGroup
.FormArray
supports homogeneously typed controls.UntypedFormArray
is a non strongly-typedFormArray
.- The
nonNullable
supports resetting a control state to the initial/seed value. - Through strict types and template type checking, we can avoid runtime exceptions and reduce regressions.