Back to the homepage
Angular

Typed Forms

There are new features in Reactive Forms in the 14th version of Angular. I talked about this topic during the #4 Angular Meetup. 

I’m revisiting the topic with my newfound experience to present the highlights, and describe best practices.

What does the new api bring?

In short, it types the values held in FormControls and the controls held in control containers (FormGroup, FormArray, FormRecord).

In the examples below, I will purposely create controls using constructors instead of using FormBuilder, (as good practice would dictate) in order to trace the changes to these classes step by step.

Example of control initialization:

It looks just like it used to, doesn’t it?

However, it used to be that the value of nameControl.value would have been typed as any. Now we get a value of type string | null.

Using strongly typed values makes us immune to errors resulting from processing a value in a way that is inconsistent with its type. It also makes our work easier – we can count on the help of the IDE, which prompts us, for example, for the properties of the type we are operating on.

To ensure that the value will match a given type, the entire api of the FormControl class is plugged in. This means that the methods: setValue, patchValue and reset will take as argument values that conform to the type resulting from the initialization (that is, the generic type of the control).

Example of FormGroup initialization:

FormGroup’s generic type will be equivalent to the type of its controls props. The type will be inferred from such initialization:

This will end the need for casting to the appropriate types when referring to the contained controls (previously, each control was stamped as AbstractControl, with no value type specification).

To make this possible, restrictions have been placed on the removal of controls. In order for a control to be removed from a FormGroup, it must be explicitly declared as optional. This optionality is induced on the type of value pulled from the FormGroup.

The same goes for adding controls. If we want to add a control that was not present in the initial value, we need to add it to the FormGroup’s generic type as optional.

Specifying a generic type directly

Specifying a generic type for a complex form is a tedious task that greatly complicates its declaration. We should therefore avoid it, relying as much as possible on typescript type inference. Optionality of controls is one of the few cases where its declaration is necessary.

It seems to me that this case is quite rare, and due to its complexity I will not elaborate on it here. In the future there will be a separate article in the form of “case studies” where I will delve into this topic.

Another case where the type won’t be inferred automatically is when the form is passed via input to a child component. However, we can avoid it by changing the architecture of sharing the form between components. 

E.g., we can elevate the logic of its creation to a separate service, the so-called presenter, which will be provided at the level of the parent component and injected in both components allowing convenient sharing of the form and relying on the type inferred from the initialization.

What when we don’t know in advance the controls we will need?

Someone may immediately say – “Dominik, but I don’t know in advance what controls and under what keys I will keep in my form, they are dynamically added based on user actions or data received from the server!”.

To solve such a problem, we can use FormArray, or the new FormRecord class, which, like FormGroup, stores controls under keys without requiring them to be declared at initialization. 

But let’s start with FormArray – where we can add and remove controls at will, with the caveat that they must be controls that match FormArray’s generic type.

Example of FormArray initialization:

The type of controls that FormArray can store is inferred from the controls entered at initialization and/or from the generic type given explicitly. In the case of an empty FormArray, it is necessary to declare it explicitly – if we initialize it with an empty array of controls and do not specify it, the FormArray will be useless – we will not be able to add controls of any type to it. The correct initialization looks as follows:

Some of you may say to me now: “Dominik, but I don’t want to keep these dynamically added controls as a list! I want to be able to refer to them by their keys!” And of course this is a useful solution – so let’s move on to the new FormRecord class.

Example of FormRecord initialization:

In this example, we have a double dynamic structure – a map of authors with a list of their books. The generic type will be the type of control the FormRecord can hold – in this case it will be FormArray<FormControl<string | null>>. When initializing an empty FormRecord, we should pass it explicitly.

Where does the null in the type come from?

As you may have noticed, null appears in the generic type of initialized FormControls. It comes from the fact that when using the reset method, the value of the controls resets to null by default – in the type we have to take into account that this can happen. 

Such resetting is potentially dangerous. We can accidentally insert null in a place where we don’t expect it at all, and thus introduce an error into our code that can be difficult to detect. An example of this would be an addressForm declared earlier with the default value of the county = ‘PL’ control. Resetting this form without specifying a value will assign null to the country, losing the default value ‘PL’.

Much more often, we expect a form reset to restore the form’s shape from before user interaction. To make this possible, a new nonNullable property was added to the props configuration.

Example of initializing a nonNullable FormControl:

Thus, resetting the control will restore its initial value. There is also the benefit of narrowing the generic type by null.

It’s time for FormBuilder (and even the NonNullableFormBuilder):

Using FormBuilder reduces the amount of code needed to initialize a form. If we create a FormGroup or FormArray then we don’t need to pass values wrapped in FormControls to it – it will do it for us!

Example of form initialization using FormBuilder (injected into the component as fb):

The difference is particularly noticeable if, according to good practice, we would like to set all controls as nonNullable. For this, there is a special NonNullableFormBuilder class, which we can directly inject through the DI and start using it only.

How to implement a form tailored to creation and editing modes?

 

Let’s take as an example a component that contains a form. This form can be in the state of editing an entity, or creating it. It depends on whether we have passed it data via input, with which we initialize it in edit mode. 

A common practice is to initialize the form in the OnInit hook. Then, if the data exists then we create it using it. Otherwise, we use default values.

However, this approach has a problem – by separating the declaration from the initialization, we don’t allow typescript to infer what the form type will be. 

To address this problem, let’s initialize the form with default values right at declaration, and if the data comes in, fill it with them using the setValue or patchValue method.

This will allow us to have a fully populated form. In addition, by sticking to this convention and using NonNullableFormBuilder, we will achieve the predictability of the reset method – it will always reset the form to its default value, which is where it is initially in creation mode.

In the above case, all controls store values of string type. Then the value of the default empty control can simply be an empty string. 

What about controls of type Number or Date? Then we may want their initial value to be a null, for example, instead of some default number or date. A good solution would be to cast the “initial null” as a type that the control will contain.

Example:

Migration to Typed Forms

Update to v14 changes all uses of form classes to their unpatched equivalents (UntypedFormControl, UntypedFormGroup, UntypedFormBuilder, etc.). This will allow us to gradually transition to the typed api, and easily identify which forms have already been refactored.

Best practices

I have collected below good practices and tips for using typed forms.

  • If you haven’t already – update Angular and start using them!
  • Rely on the type inferred by typescript, avoid declaring it explicitly (exceptions are the initially empty FormArray and FormRecord).
  • Beware of specifying the form type explicitly – the form declaration should always be combined with its initialization. If you need to fill it with data not available at constructor time, do it using the setValue/patchValue method when the data is available.
  • Declare controls as nonNullable.
  • Use FormBuilder and preferably NonNullableFormBuilder to build forms and controls.
  • Referring to nested controls using the controls props instead of the get method. Using get loses the type of the control keeping only the generic type consistent (instead of FormControl<string> you get AbstractControl<string> | null).
  • When retrieving the value of the control container, remember that using the value props will skip controls in the disabled state. This is also accounted for in their type – each is prepended with undefined in case the control would be in the disabled state. If you don’t want controls in the disabled state to be skipped and undefined included in their value type, you can use the getRawValue method. You can also get value  from the control instead of its container.

Are we sure that the stored value will match the declared type?

Unfortunately, no.

There is always a danger that the value of a variable will be incompatible with the declared type. To reduce this danger, we should take care of strong typing, such as using strict mode.

Unfortunately, incompatibility of values with the declared type can also occur at the point where the control is bound to the input element. At this point, Angular does not provide a mechanism for verifying the consistency between the type of values provided by the input and the type the control expects.

Example:

The compiler will not detect the error, and after the user’s interaction, numbers will start to be entered into the control. At this point, however, we cannot remedy this. The author of Typed Forms points out that this is an obvious downside, but there are prospects for solving this problem in the future (see RFC, section Limitations – Control Bindings).

At the end

We strongly encourage you to use typed forms in your projects! 

Share in the comments your impressions of using them, problems you encountered, and what you think of this solution!

About the author

Dominik Kalinowski

Angular Developer at https://houseofangular.io/, fan of strict typing, unit tests, UX and meticulous code review. Always tries to write really legit code.

Don’t miss anything! Subscribe to our newsletter. Stay up-to-date with the latest trends, tips, meetups, courses and be a part of a thriving community. The job market appreciates community members.

Leave a Reply

Your email address will not be published. Required fields are marked *