DEV Community

Cover image for Angular Reactive Typed Forms - Not just a dream
Anastasios Theodosiou
Anastasios Theodosiou

Posted on

Angular Reactive Typed Forms - Not just a dream

It's been a while since I last wrote an article. When the new Angular version 14 was released I was quite satisfied with two new features and I wanted to share it with you. The first is Typed Reactive Forms and the second is Standalone Components.

Original Source: Anastasios Theodosiou Blog

After 6 years of the first release, and after months of discussion and feedback, the most needed feature and up-voted issue in the Angular repository is now solved in Angular v14!

Angular 14 was released 2nd of June with the most significant update since Ivy. It includes two long-awaited features, Typed Reactive Forms and Standalone Components, as well as several minor improvements.

On this article we will focus on Typed Reactive Forms. As before Angular v14, Reactive Forms did not include type definitions in many of its classes, and TypeScript would not catch bugs like in the following example during compilation.

  const loginForm = new FormGroup({
    email: new FormControl(''),
    password: new FormControl(''),
  });

  console.log(login.value.username);
Enter fullscreen mode Exit fullscreen mode

With Angular 14, the FormGroup, formControl, and related classes include type definitions enabling TypeScript to catch many common errors.

Migration to the new Typed Reactive Forms is not automatic.

The already existing code containing FormControls, FormGroups, etc.. will be prefixed as Untyped during the upgrade. It is important to mention that if developers would like to take advantage of the new Typed Reactive Forms, must manually remove the Untyped prefix and fix any errors that may arise.

More details about this migration can be found at the official Typed Reactive Forms documentation.

A step by step migration example of an untyped reactive form

Let's say that we have the following register form.

  export class RegisterComponent {
    registerForm: FormGroup;

    constructor() {
      this.registerForm = new FormGroup({
        login: new FormControl(null, Validators.required),
        passwordGroup: new FormGroup({
          password: new FormControl('', Validators.required),
          confirm: new FormControl('', Validators.required)
        }),
        rememberMe: new FormControl(false, Validators.required)
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

Angular also provided an automated migration to speed up the process. This migration will run when we as developers, run the following command.

ng update @angular/core or on demand, if we already manually updated your project by running the next command. ng update @angular/core --migrate-only=migration-v14-typed-forms .

In our example, if we use the automated migration, we end up with the above changed code.

export class RegisterComponent {
  registerForm: UntypedFormGroup;

  constructor() {
    this.registerForm = new UntypedFormGroup({
      login: new UntypedFormControl(null, Validators.required),
      passwordGroup: new UntypedFormGroup({
        password: new UntypedFormControl('', Validators.required),
        confirm: new UntypedFormControl('', Validators.required)
      }),
      rememberMe: new UntypedFormControl(false, Validators.required)
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The next step now is to remove all the Untyped* usage and adjust properly our form.

Each UntypedFormControl must be converted to FormControl, with T the type of the value of the form control. Most of the time, TypeScript can infer this information based on the initial value given to the FormControl.

For example, passwordGroup can be converted easily:

passwordGroup: new FormGroup({
  password: new FormControl('', Validators.required), // inferred as `FormControl<string | null>`
  confirm: new FormControl('', Validators.required) // inferred as `FormControl<string | null>`
})
Enter fullscreen mode Exit fullscreen mode

Note that the inferred type is string | null and not string. This is because calling .reset() on a control without specifying a reset value, resets the value to null. This behavior is here since the beginning of Angular, so the inferred type reflects it. We’ll come back to this possibly null value, in an example bellow, as it can be annoying (but there is always a way).

Now let’s take the field registerForm. Unlike FormControl, the generic type expected by FormGroup is not the type of its value, but a description of its structure, in terms of form controls:

registerForm: FormGroup<{
  login: FormControl<string | null>;
  passwordGroup: FormGroup<{
    password: FormControl<string | null>;
    confirm: FormControl<string | null>;
  }>;
  rememberMe: FormControl<boolean | null>;
}>;

constructor() {
  this.registerForm = new FormGroup({
    login: new FormControl<string | null>(null, Validators.required),
    passwordGroup: new FormGroup({
      password: new FormControl('', Validators.required),
      confirm: new FormControl('', Validators.required)
    }),
    rememberMe: new FormControl<boolean | null>(false, Validators.required)
  });
}
Enter fullscreen mode Exit fullscreen mode

Nullability in forms

As we can see above, the types of the controls are string | null and boolean | null, and not string and boolean like we could expect. This is happening because if we call the .reset() method on a field, resets its value to null. Except if we give a value to reset, for example .reset(''), but as TypeScript doesn’t know if and how you are going to call .reset(), the inferred type is nullable.

We can tweek behavior by passing the nonNullable options (which replaces the new option introduced in Angular v13.2 initialValueIsDefault). With this option, we can get rid of the null value if we want to!

On one hand, this is very handy if your application uses strictNullChecks, but on the other hand, this is quite verbose, as we currently have to set this option on every field (hope this change in the future).

registerForm = new FormGroup({
  login: new FormControl<string>('', { validators: Validators.required, nonNullable: true }),
  passwordGroup: new FormGroup({
    password: new FormControl('', { validators: Validators.required, nonNullable: true }),
    confirm: new FormControl('', { validators: Validators.required, nonNullable: true })
  }),
  rememberMe: new FormControl<boolean>(false, { validators: Validators.required, nonNullable: true })
}); // incredibly verbose version, that yields non-nullable types
Enter fullscreen mode Exit fullscreen mode

One other way to achieve the same result, is to use the NonNullableFormBuilder. A new property introduced by Angular v14 called nonNullable, that returns a NonNullableFormBuilder which contains the usual as known control, group, array, etc. methods to build non-nullable controls.

Example of creating a non-nullable form grop:

constructor(private fb: NonNullableFormBuilder) {}

registerForm = this.fb.group({
  login: ['', Validators.required]
});
Enter fullscreen mode Exit fullscreen mode

So, does this migration wort it? What do we gain with Typed Reactive Forms?

Before Angular v14, the existing forms API does note performing very well with TypeScript because every form control value is typed as any. So, we could easily write something like this.registerForm.value.something and the application would compile successfully.

This is no longer the case: the new forms API properly types value according to the types of the form controls. In my example above (with nonNullable), the type of this.registerForm.value is:

// this.registerForm.value
{
  login?: string;
  passwordGroup?: {
    password?: string;
    confirm?: string;
  };
  rememberMe?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

We can spot some ? in the type of the form value. What does it mean?

It is widely known that in Angular, we can disable any part of our form we wan to and if so, Angular will automatically remove the value of a disabled control from the value of the form.

this.registerForm.get('passwordGroup').disable();
console.log(this.registerForm.value); // logs '{ login: null, rememberMe: false }'
Enter fullscreen mode Exit fullscreen mode

The above result is a bit strange but it explains sufficiently why the fields are marked as optional if they have been disabled. So, they are not part of the this.registerForm.value any more. TypeScript calls this feature Partial value.

There is also a way to get the hole object even with the disabled fields, by running the .getRawValue() function on the form.

{
  login: string;
  passwordGroup: {
    password: string;
    confirm: string;
  };
  rememberMe: boolean;
} // this.registerForm.getRawValue()
Enter fullscreen mode Exit fullscreen mode

Even more strictly typed .get() function

The get(key) method is also more strictly typed. This is great news, as we could previously call it with a key that did not exist, and the compiler would not see the issue.

Thanks to some hardcore TypeScript magic, the key is now checked and the returned control is properly typed! It is also works with array syntax for the key as bellow.

his.registerForm.get('login') // AbstractControl<string> | null
this.registerForm.get('passwordGroup.password') // AbstractControl<string> | null

//Array Syntax
this.registerForm.get(['passwordGroup', '.password'] as const) // AbstractControl<string> | null
Enter fullscreen mode Exit fullscreen mode

Also works with nested form arrays and groups and if we use a key that does not exist we can finally get an error:

this.registerForm.get('hobbies.0.name') // AbstractControl<string> | null 

//Non existing key
this.registerForm.get('logon' /* typo */)!.setValue('cedric'); // does not compile
Enter fullscreen mode Exit fullscreen mode

As you can see, get() returns a potentially null value: this is because you have no guarantee that the control exists at runtime, so you have to check its existence or use ! like above.

Note that the keys you use in your templates for formControlName, formGroupName, and formArrayName aren’t checked, so you can still have undetected issues in your templates.

Something fresh: FormRecord

FormRecord is a new form entity that has been added to the API. A FormRecord is similar to a FormGroup but the controls must all be of the same type. This can help if you use a FormGroup as a map, to which you add and remove controls dynamically. In that case, properly typing the FormGroup is not really easy, and that’s where FormRecord can help.

It can be handy when you want to represent a list of checkboxes for example, where your user can add or remove options. For example, our users can add and remove the language they understand (or don’t understand) when they register:

languages: new FormRecord({
  english: new FormControl(true, { nonNullable: true }),
  french: new FormControl(false, { nonNullable: true })
});

// later 
this.registerForm.get('languages').addControl('spanish', new FormControl(false, { nonNullable: true }));
Enter fullscreen mode Exit fullscreen mode

If we try to add a control of a different type, TS throws a compilation error!

But as the keys can be any string, there is no type-checking on the key in removeControl(key) or setControl(key). Whereas if you use a FormGroup, with well-defined keys, you do have type checking on these methods: setControl only allows a known key, and removeControl only allows a key marked as optional (with a ? in its type definition).

If we have a FormGroup on which we want to add and remove control dynamically, we’re probably looking for the new FormRecord type.

Conclusion

I’m very excited to see this new forms API in Angular! This is, by far, one of the biggest changes in recent years for developers. Ivy was big but didn’t need us to make a lot of changes in our applications. Typed forms are another story: the migration is likely to impact dozens, hundreds, or thousands of files in our applications!

The TypeScript support in Angular has always been outstanding, but had a major blind spot with forms: this is no longer the case!

So, yes. It is totally worth it!!

Till next time,
Happy coding.

Top comments (0)