LiveLoveApp logo

Typed Forms in Angular

Published by Brian Love on
Typed Forms in Angular
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 to UntypedFormControl
  • FormGroup is migrated to UntypedFormGroup

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 the SignUpForm 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 &#39;FormControl&lt;number&gt;&#39; is not assignable to parameter of type &#39;AbstractControl&lt;string, string&gt;&#39;.
Types of property &#39;value&#39; are incompatible.
Type &#39;number&#39; is not assignable to type &#39;string&#39;.

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 &#39;FormControl&lt;number&gt;&#39; is not assignable to parameter of type &#39;AbstractControl&lt;string, string&gt;&#39;.
Types of property &#39;value&#39; are incompatible.
Type &#39;number&#39; is not assignable to type &#39;string&#39;.

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, and FormArray 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-typed FormControl. Or, in other words, the TValue type is preset to any.
  • UntypedFormGroup is a non strongly-typed FormGroup.
  • FormArray supports homogeneously typed controls.
  • UntypedFormArray is a non strongly-typed FormArray.
  • 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.
Bring data to life on the web
Schedule a Call