Type Smarter, Not Harder – Get the Most of Angular Typed Forms

Drawing on our on-the-ground experience with Angular Typed Forms, we present a thorough guide coupled with best practices, enabling you to make the most of Angular’s stellar feature in your everyday projects.

Angular Typed Forms, one of the more recent and certainly the most significant enhancements to the Reactive Forms API, enable you to – add types to your Angular forms. 

Initially introduced as a developer preview in Angular 14, Angular Typed Forms have been deemed stable as of Angular 15. This particular form type helps us catch errors early in development, enhances code readability and maintainability, and supports useful features like auto-completion. 

The feature, which was the top-requested enhancement for several years, was finally implemented by the Angular team in response to community feedback. As Angular Typed Forms have matured, our team at Infinum has accumulated ample practical experience in their use. In this article, we aim to share what we have learned and bring you practical recommendations and proven best practices so you can make the most of Angular’s prized feature. 

What Angular Typed Forms do and don’t do

The main goal of typed forms is to maximize type safety and enhance the developer experience in creating Reactive Forms. The Angular team wanted to support their gradual adoption, so they enabled the use of both typed and untyped forms to prevent disrupting existing applications.

Angular Typed Forms are not designed to support template-driven forms or non-model classes such as validators. They are also not intended to alter the underlying functionality of forms; all current form APIs remain the same.

From untyped to typed – the evolution of Angular forms

Let’s explore how FormGroup and FormControl, key components of Angular’s reactive forms, are used in versions before Angular 14 and in version 14 and beyond.

Consider the following simple code using FormGroup with one FormControl:

	private readonly exampleFormGroup = new FormGroup({
   exampleControlOne: new FormControl('exampleOne'),
 });

// Template
<input type="text" [formControl]="exampleFormGroup.controls.exampleControlTwo" />

In versions preceding Angular 14, you could write this kind of code without knowing if the control exists inside the FormGroup. This example is obvious, but it could have easily happened that you made a tiny typo and spent quite a lot of time debugging. 

With typed forms, you get the following error:

	// Property 'exampleControlTwo' does not exist on type '{ exampleControlOne: FormControl<string | null>; }'.

On top of getting an error message, you are less likely to make this type of error in the first place because the autocomplete feature will give you a list of all of the controls.

Another big improvement Angular Typed Forms brings is in the usage of FormControl values. 

Let’s say a child component has an input that accepts a value of some type; for example, string, and we want to pass our FormControl value. 

	private exampleControlOne = new FormControl(true);

//Template
<app-child [exampleStringInput]="exampleControlOne.value"></app-child>

In this example, you might get runtime errors but won’t get build-time errors, even though we passed a boolean value to a string input. In the Angular versions below 14, all FormControl values are typed as any.

This is what happens with typed forms:

	private exampleControlOne = new FormControl(true);

//Template
<app-child [exampleStringInput]="exampleControlOne.value">
</app-child>
// Type 'boolean' is not assignable to type 'string'.

We get a pretty obvious TypeScript error, which is a huge win.

FormControl

In Angular 14 and beyond, form controls are typed by default. You don’t have to do anything special to create your first typed FormControl.

	private exampleControlTwo = new FormControl('');
// FormTestComponent.exampleControlTwo: FormControl<string | null>

The type resulting from the above control is FormControl<string | null>. Angular automatically infers the control type from the initial value.

What if our control value can be of multiple types, for example, a number and a string? In this case, Angular cannot know all the possible value types, so we need to supply them as a Typescript generic argument to FormControl.

	private exampleControlTwo = new FormControl<string | number>('');
 // FormTestComponent.exampleControlTwo: <string | number | null>

Now, why does null appear in the list if we said the FormControl type is either string or number? Every typed FormControl can be null by default, i.e., they are nullable. This happens because every FormControl can be reset, and if you call the .reset() method, it will set the value to null, so the type is indeed correct. However, if this is not a desired behavior for your use case, you can change it by passing a nonNullable flag as an option to FormControl. With the flag set to true, the FormControl will reset to the initial value instead of null.

	 private exampleControlTwo = new FormControl('', {nonNullable: true});
// FormTestComponent.exampleControlTwo: FormControl<string>

What if you do not supply the initial value and a generic argument?

	 private exampleControlTwo = new FormControl();
// FormTestComponent.exampleControlTwo: FormControl<any>

The resulting type will be FormControl<any>. This is because we didn’t supply any type information or initial value to FormControl, and Angular can’t infer it.

In most cases, Angular will infer the type of FormControl correctly by default. We prefer this approach over stating the types explicitly unless we really need them.

FormArray

The inferred FormArray type will be the result of all its inner control types. If you want to have different types of controls inside the array, you must use UntypedFormArray. They are created in the same way, and everything said in the FormControl section applies to FormArray as well.

	private exampleFormArray = new FormArray([new FormControl('')]);
// FormTestComponent.exampleFormArray: FormArray<FormControl<string | null>>

FormGroup

FormGroup behaves the same as in previous Angular versions, the only exception being the difference between value and rawValue explained later on.

Note: It is possible to mix and match typed and untyped form controls in the typed FormGroup.

	private exampleFormGroup = new FormGroup({
   exampleControl: new FormControl(''),
 });

// FormTestComponent.exampleFormGroup: FormGroup<{
//   exampleControl: FormControl<string | null>;
// }>

FormBuilder

FormBuilder also got an upgrade in the form of typed support. All the rules explained above apply to FormBuilder as well. If you want to create a form with all the controls set as non-nullable, just use the NonNullableFormBuilder.

Form value and rawValue types

Angular has no built-in utility type for inferring the type of value property nor a getRawValue method. Because we needed one of those, we created a utility type that you can also use. The helpers are part of the ngx-nuts-and-bolts package on the NPM.

To extract a form value or a raw value as a type and use it in multiple places within a project, we need to follow two simple steps.

1

Create a helper function that will return FormGroup, FormControl, etc.

2

Create a new type that uses FormValue and RawFormValue utility types and pass in the return type of the function we created.

	function createExampleFormGroup() {
 return new FormGroup({
   exampleControlOne: new FormControl('exampleOne'),
   exampleControlTwo: new FormControl('exampleTwo', { nonNullable: true }),
 });
}

type ExampleFormValue = FormValue<ReturnType<typeof createExampleForm>>;
type ExampleRawFormValue = RawFormValue<ReturnType<typeof createExampleForm>>;

// Results in the following types:
// type ExampleFormValue = {
//  exampleControlOne?: string | null | undefined;
//  exampleControlTwo?: string | undefined;
// }

// type ExampleRawFormValue = {
// exampleControlOne: string | null;
// exampleControlTwo: string;
// }

The types we just created can now be used for typing function arguments, nested form groups, etc.

Notice how in the case of a form we also get undefined as a possible type. This is because every control can be disabled, which brings us to the explanation. Disabled form controls won’t be included in the resulting object when you call .value on a control. Should you wish to always get the value of a control, use .getRawValue() instead. The choice what route to take here will depend on your specific use case.

	private readonly exampleFormGroup = new FormGroup({
   exampleControlOne: new FormControl({ value: 'exampleOne', disabled: true }),
   exampleControlTwo: new FormControl('exampleTwo', { nonNullable: true }),
 });

// console.log(this.exampleFormGroup.value);
// {exampleControlTwo: 'exampleTwo'}

// console.log(this.exampleFormGroup.getRawValue());
// {exampleControlOne: 'exampleOne', exampleControlTwo: 'exampleTwo'}

Should you refactor and how to do it?

Refactoring your Angular forms is optional and will depend on many factors, such as the size of your projects, the complexity and number of forms used, and your team or company’s strategic direction. Refactoring a project that has only one form is not very hard, but multiple ones can present a problem. However, in most scenarios, the refactoring is worth the effort. Don’t be surprised if you end up discovering bugs that you never knew existed before.

As we often recommend, it’s best to refactor parts of code when you are already modifying it in the course of new development or bug-fixing. The next time you update a form, consider upgrading at least a part of it to utilize typed forms.

Backward compatibility with untyped controls and migrations

Angular Typed forms are fully backward compatible, which means you can update your Angular version without fear you’ll need to refactor your existing application extensively. Upon updating to Angular 14, all your forms will be automatically migrated to their respective untyped versions. FormControl becomes UntypedFormControl and so on.

Limitations, quirky behaviors, and best practices

No support for template-driven forms

One of the limitations of Angular Typed Forms is that the Typed Forms API doesn’t work with template-driven forms. If you are working on an older project with many template-driven forms you won’t be able to enjoy these new benefits.

Binding form controls directly

Our recommendation is to always aim to bind form controls directly in the template using formControl instead of formControlName. This will enable you to catch errors as early as possible or not even make them in the first place. 

Using this approach, you’ll be thrown an error if you try to access a control that does not exist, and you will also benefit from the auto-complete function, which further reduces the chance of making an error. Additionally, you protect yourself for the case of future changes because renaming or deleting a FormControl will result in an error in the template.

	// Component
 private readonly exampleFormGroup = new FormGroup({
   exampleControlOne: new FormControl('exampleOne'),
   exampleControlTwo: new FormControl('exampleTwo', { nonNullable: true }),
 });

// Template
<form [formGroup]="exampleFormGroup">
 <input formControlName="exampleControlThree" />
</form>
// Won't throw an error

<form [formGroup]="exampleFormGroup">
 <input [formControl]="exampleFormGroup.controls.exampleControlThree"
/>
</form>
// Will throw an error in compile time

The same recommendation applies for accessing form controls in components. Avoid using get to catch errors earlier in the development process.

	private readonly exampleFormGroup = new FormGroup({
   exampleControlOne: new FormControl('exampleOne'),
   exampleControlTwo: new FormControl('exampleTwo', { nonNullable: true }),
 });

constructor() {
// Won't throw an error 
this.exampleFormGroup.get('exampleControlThree')

// Will throw an error 
this.exampleFormGroup.controls.exampleControlThree
}

Why Angular Typed Forms are a win-win

Type-safe forms in Angular represent one of the most significant changes in recent Angular history. They don’t require an immediate refactor of your entire codebase, and the migration process is straightforward. In addition, they enhance the developer experience by eliminating the possibility of accessing non-existent attributes or assigning unsupported values to form controls. 

In fact, there’s hardly a downside to typed forms. They also prevent many bugs while improving maintainability throughout the project’s lifecycle. Based on experience and the knowledge we’ve attained so far, we’d definitely recommend using Angular Typed Forms on all new projects by default, as well as adopting them in existing ones when creating new forms. As always, we look forward to new additions and improvements to the framework and can only say – always bet on Angular.