Introduction
In version 14 of Angular a new feature was added to Reactive Forms, and that was type-safe forms. Prior to this addition, you could more easily introduce runtime errors in your reactive forms, as you could access values and controls on a form that didn't exist. You could also set the value of a control to whatever value you wanted; for example you could set the value of an email control to a number, even though you would never want that to happen.
With type safety being introduced, these runtime pitfalls are largely eliminated. You will be warned if you try to access an attribute that does not exist, or try to set the value of a field to an unsupported type.
In this article, we'll look at what you need to do to implement typed reactive forms, and some of the benefits and strategies needed while working with them.
Creating Typed Forms
The first step in using typed forms is creating a typed form. There are multiple methods of doing this: creating a FormControl
, creating a FormGroup
, and using the FormBuilder. I'll show brief examples of creating a FormControl and creating a FormGroup
, but the majority of the examples will use the FormBuilder. This is a personal preference of mine, but there's not a “right” way to do it.
FormControl
You can create a FormControl simply by using the new keyword and passing an initial value to the FormControl constructor:
public name = new FormControl('')
The above example gives us a new FormControl
whose value will be string or null; I'll cover why its type is string or null momentarily. If you were to try and set the value of the control to a number, you will get an error in your IDE:
Just by creating the control, we get some type safety automatically. You can also explicitly set the value of the field when creating the control.
public name = new FormControl<string | number>('');
With this definition, our example of setting the value to a number no longer produces an error.
FormGroup
You can also create typed reactive forms by creating a new FormGroup. It is similar to creating a simple FormControl
.
public form = new FormGroup({
name: new FormControl(''),
});
The name control of this form will have the same type safety that the FormControl
above has. If we try and set the value of the field to a number, we will get an error:
In addition to the type safety of the name control, you will get an error if you try to set the value of a field that doesn't exist on the FormGroup declaration.
This type safety will help you avoid potential errors in your application, such as setting the value of an attribute to an unsupported type or patching the form with unsupported values. You should know exactly what is on the value of the form at any given time.
FormBuilder
The FormBuilder
is a third way you can create typed forms. It is very similar to creating a new FormGroup, but creates the controls for you instead of you explicitly creating the controls. There are some other differences, but at first glance that's what stands out the most.
public form = this._fb.group({
name: [''],
});
The same type safety is given on this form as in the FormGroup section above, so I won't cover it again here. You can also type the form by providing an interface as a generic for the FormBuilder
's group method:
interface InfoForm {
name: string;
age: number;
}
public form = this._fb.group<InfoForm>({
name: '',
age: 0,
});
This form of typing works great, as it will alert you if you haven't implemented one of the attributes, and helps to make sure you give the attributes the right value. If you want to add validators to the attributes, though, the above interface will produce errors, because you explicitly said the value of “name” would be a string, but you'll be setting its value to an array:
Generally speaking, the explicit typing with the InfoForm interface is extra and not needed. It doesn't give you more type safety than not providing the interface.
FormControl Values
I mentioned above that the value of a FormControl (whether created with the new keyword, or with a FormGroup or the FormBuilder
) can also be null. By default, all the values are either the type that you provide as the initial value or null or undefined. Let's talk about why the values could be null or undefined.
First, values can be undefined because the control can be disabled, and that means that the value of that control will not be included on the form's value. You can get the raw value of the form, with getRawValue()
, and you'll not run into any undefined attributes.
Next, the value can be null because that's just the default behavior of typed forms. The value can be null unless explicitly stated otherwise. If you reset the control without providing a new value for the control, its reset value will be null. If you want the field to be non-nullable, you would create the FormControl
or FormGroup
in the following manner:
public name = new FormControl('', { nonNullable: true });
// or
public form = new FormGroup({
name: new FormControl('', { nonNullable: true }),
});
// or
public form = this.\_fb.nonNullable.group({
name: [''],
});
// or
private \_fb = inject(NonNullableFormBuilder);
public form = this.\_fb.group({
name: [''],
});
If you declare the FormGroup
or FormControl
in any of the above ways, the value of the controls can not be null any longer. undefined is still fair game, as is the type of the initial value, but null will no longer be valid.
Benefits of Type Safe Forms
There are too many benefits of type safe forms to reasonably name in an article, but here are some that come to mind that you are most likely to run into on a day-to-day basis.
The first is autocomplete in your IDE. We all rely heavily on our IDEs to get our daily work done. It allows us to not have to memorize every attribute of an object or every method in a class. All we need to do is hit a key combo and have the autocomplete menu pop up, or hover over the variable and see the information provided for us. For example, let's look at this valueChanges pipe from our form that we declared above. When I tap in the stream and hover over the values parameter, I can see what to expect on the object:
This form was created with the nonNullable option, which is why the values of name and age don't include null.
You get the same benefit of autocompletion when trying to access the controls on the form, perhaps to use the valueChanges observable on an individual control instead of the whole form:
My two options are age and number; if I tried to add any other control there, I'd get an error and the app wouldn't compile. If we were not using typed forms, I could add whatever control I wanted there and the app would fail at runtime instead of compile time.
The last thing I want to point out is similar, but relates to patching or setting the value of a form in your Typescript file. If you try and add an invalid attribute to the object, the app fails to compile and your IDE will warn you:
With untyped forms, you would be able to provide that value to the patchValue
method, and may expect to have an emailAddress
on the form. This level of help that comes automatically from the framework and your IDE is extremely beneficial. Anything I can do to limit the amount of mistakes I make is welcome.
Common Gotchas with Typed Forms
With all the good things that come from typed forms, there are a couple areas where you may run into a couple issues. These are things where the framework is working as expected, but you might feel like it's getting in your way a little bit.
Initial Value as null
The first thing, which I was running into a lot, is related to the value of a control when trying to update with setValue
or patchValue
. Let's look at the following example form:
public form = this._fb.group({
name: '',
age: [null, Validators.required],
});
In this form, the beginning value of age is null, and it is a required field. I'll do this sometimes to ensure that the user explicitly selects an age. 0 could be a valid age, so setting it to 0 to begin with isn't an option. The issue here is that the only valid values for age are now null and undefined. If I try and manually patch the form, or interact with the value of age at some point, the type of the value will be null or undefined.
To overcome this, you will need to set the initial value of the control to a number, like -1. Then add another validator to the control, the min value validator, and set its value to 0. That way they still have to explicitly change the age, and they can select 0 as an option. This works, but it really works best if the age control is a select element in the HTML. You could set the default option's value to -1, and then they can choose their age after that. I don't love this option because it feels kind of hacky, but it would work.
There is a way to initialize the form correctly, though, that doesn't require any hackery. It involves explicitly declaring a control and its types for the age control. It is similar to our above examples of typing the control, but also still using the FormBuilder
. Here's the example:
public form = this._fb.group({
name: '',
age: this._fb.control<number | null>(null, [Validators.required]),
});
When we use the FormBuilder
to build the form, each attribute is automatically created as a control with the default data and validators we provide. But we can explicitly create the control as well with the FormBuilder
class as shown, and give the control the types we want.
In that same vein, we could make a single field non-nullable if we needed instead of only making the form as a whole non-nullable. Here's an example of making the name attribute non-nullable:
name: this._fb.nonNullable.control('', [Validators.required]);
With this declaration, we can be sure that the value of name will never be null when working with it in your application.
FormGroup Typed as any
The next gotcha relates to where we initialize the FormGroup
. For a long time, the conventional, way to declare variables in an Angular component looked like this:
export class MyComponent {
public form: FormGroup;
ngOnInit() {
this.form = this._fb.group({ … })
}
}
While this is valid, it does ruin our type safety. That's because the declaration of the form variable sets the type to FormGroup
, which defaults to FormGroup<any>
. Thus, we will get no autocomplete or type helping throughout the component. You can fix the typing of the form in one of two ways:
- initialize the form at the time of declaration; don't initialize the value in ngOnInit or even the constructor
- Give a more detailed, explicit type for the
FormGroup
when declaring the variable:
public form!: FormGroup<{ age: FormControl<number | null>, name: FormControl<string |null> }>;
Either of these methods restore your ability to have the type safe forms we've discussed throughout this article.
Conclusion
Having your forms typed is a much better experience in Angular than untyped forms. Perhaps my favorite thing about them is that there is very little to do to gain the benefits on the part of the developer. You basically have to go out of your way to lose the type safety. There are a lot of things we have to do in our apps to have them be type safe, but this is not one of those things.
Also, as I mentioned previously, it's very nice to be warned in advance if your form is not valid. It's never fun to deploy your application and then have a bug come up that requires you to diagnose the issue while it's in production and get a fix out. Knowing at compile time is always better, and the Angular team has provided us with that benefit.