Typed Forms are finally here!
Bundled within the massive list of changes that were rolled out in Angular 14 was one of the most highly requested features, going back 4+ years on the request boards. As Angular is one of the only natively TypeScript frameworks it makes sense that developers would want to utilize the hard typing ability of the language everywhere possible. A commonly used phrase in TypeScript is that “Any is the enemy” because if you do not provide object types you lose out on using IntelliSense and reduce the TypeScript compiler’s ability to catch errors as they might be introduced. Most of the time this was simple; toss a type onto your objects, methods, arrays, etc. and call it a day, but one of the most commonly used items in Angular stood defiant: forms.
How was it fixed?
Angular took, in my opinion, an unexpected yet effective approach to allowing developers to solve the issue at hand. When creating a FormControl, the property type of the initial value is used to declare the type of that control. As an example, with the new update we still declare a FormControl the same way that we have been:
let messageControl = new FormControl('Hello World!');
The new control, named messageControl, gets its type from the input value ‘Hello World!’ and now anytime that control’s value is accessed, Typescript will infer that the output should be a string. This makes perfect sense, we are passing in a string so we should probably expect a string to come back out. However, what if we don’t want the control to have a starting value? What if I want to pass in a null but still have the output value typed as a string? That’s where the Typed Form changes come in!
let messageControl = new FormControl<string>(null);
You can now provide a subtype to the FormControl constructor that allows you to provide a null value into the constructor and still have the FormControl set to any type that you desire. It could be a basic type (string, number, boolean, etc) or something more complex. But, this is just a tedious way of mimicking our interface’s control by single control and obviously isn’t the best method for constructing forms that are supposed to adhere to entire interfaces or classes. Why can’t we just pass our custom type and have the form validate itself against it? Well, that is a great idea, let’s get to it!
Custom Form Type
1. The first step would be creating your desired interface, this will tell your FormGroup what controls it should have and of what types. For this example, we will have a user registration interface that will incorporate various properties of different basic types.
interface IUserRegistration { email: string; age: number; subscriber: boolean; password: string; }
2. Next we want to implement a small chunk of helper code to convert our interface into a type that overlaps with a standard FormGroup. This will be key in allowing Typescript to keep our property types attached to where we need them to be. It basically converts any properties in your class or interface into a FormControl of that property’s type, removing the need to build out another custom interface just for that particular form.
type TypedForm<T> = FormGroup<{ [K in keyof T]: FormControl<T[K]>; }>;
3. Finally we create a form, using the FormBuilder, the exact same way we normally would. The only difference is that when we declare the form property, we use our custom TypedForm with IUserRegistration as the subtype. Now when we call FormBuilder.group(), the property types from our interface are kept!
import { Component } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; export type TypedForm<T> = FormGroup<{ [K in keyof T]: FormControl<T[K]>; }>; export interface IUserRegistration { email: string; age: number; subscriber: boolean; password: string; } @Component({ selector: 'app-user-registration', templateUrl: './user-registration.component.html', styleUrls: ['./user-registration.component.scss'] }) export class UserRegistrationComponent { public userRegistrationForm: TypedForm<IUserRegistration>; public testObject: IUserRegistration; constructor( private formBuilder: FormBuilder ) { this.userRegistrationForm = this.formBuilder.group({ email: ['', [Validators.required, Validators.email]], age: [null as number, [Validators.required]], subscriber: [false, [Validators.required]], password: ['', [Validators.required, Validators.minLength(8)]], }); this.testObject = this.userRegistrationForm.getRawValue(); } }
A Note About FormBuilder
There is a slight “gotcha” with using FormBuilder.group() that developers should be aware of. You might have noticed the null as number as an initial value for age. Because the types are passed through, you cannot provide null as a starter value because FormBuilder wants a value that exactly matches the type expected. Even if we change the age property in the interface to be number | null, that still won’t work! So, rather than have an initial value of 0 which might look a little funny inside of a number input box, we can instead pass in null as number to allow a null starter value (empty input box) but also appease Typescript in the process.
4. Now all we need to do is check and make sure that everything works. You can see there is a testObject property that is of the same type that we assigned to our TypedForm. If all goes well, we should be able to assign this object to the form’s output of .getRawValue(). You cannot use .value as that will only return a Partial of the specified type.
As you can see, the output of .getRawValue() perfectly matches the interface used as Typescript has no objections to testObject being assigned that value! Unfortunately, the output type doesn’t specifically state the interface’s name IUserRegistration, instead giving an untyped object definition. Rest assured however that your interface is being carried through properly and you should have no issue using .getRawValue(), or even .value for a Partial return, later on in your code.
A Little Extra Credit
You can also check to ensure that the form is adhering to your custom type by changing either the type or the form and watching Intellisense pick up on the discrepancy between the two. For example, I removed subscriber as a property in my form group builder and it immediately errors because it is missing an expected property from the interface.
export interface IUserRegistration { email: string; age: number; subscriber: boolean; password: string; }
.
You get the following error: Types of property ‘controls’ are incompatible.
You can also do the reverse by changing the interface this time. In this example, I changed age to be a string rather than a number.
export interface IUserRegistration { email: string; age: string; subscriber: boolean; password: string; }
.
You get the following error again: Types of property ‘controls’ are incompatible.
So now that you can see that the form is 100% tightly coupled with the interface, you might be thinking, well since the interface and the form group must mirror one another so perfectly, what if I want to have a little wiggle room? I don’t want to have a custom interface for every single form group and variant implementation of my base class. Well, Angular’s Typed Forms update allows you to do just that as well! Since you can pass in a basic or custom type, that means you can also take full advantage of utility types; Omit and Pick to the rescue!
import { Component } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; export type TypedForm<T> = FormGroup<{ [K in keyof T]: FormControl<T[K]>; }>; export interface IUserRegistration { email: string; age: number; subscriber: boolean; password: string; exclude: boolean; } @Component({ selector: 'app-user-registration', templateUrl: './user-registration.component.html', styleUrls: ['./user-registration.component.scss'] }) export class UserRegistrationComponent { public userRegistrationForm: TypedForm<IUserRegistration>; public omitForm: TypedForm<Omit<IUserRegistration, 'exclude'>>; public pickForm: TypedForm<Pick<IUserRegistration, 'exclude'>>; public testObject: IUserRegistration; public omitObject: Omit<IUserRegistration, 'exclude'>; public pickObject: Pick<IUserRegistration, 'exclude'>; constructor( private formBuilder: FormBuilder ) { this.userRegistrationForm = this.formBuilder.group({ email: ['', [Validators.required, Validators.email]], age: [null as number, [Validators.required]], subscriber: [false, [Validators.required]], password: ['', [Validators.required, Validators.minLength(8)]], exclude: [true] }); this.omitForm = this.formBuilder.group({ email: ['', [Validators.required, Validators.email]], age: [null as number, [Validators.required]], subscriber: [false, [Validators.required]], password: ['', [Validators.required, Validators.minLength(8)]] }); this.pickForm = this.formBuilder.group({ exclude: [true] }); this.testObject = this.userRegistrationForm.getRawValue(); this.omitObject = this.omitForm.getRawValue(); this.pickObject = this.pickForm.getRawValue(); } }
In the above example you can see that using a single custom interface, IUserRegistration, I am able to create 3 distinct forms with barely any extra boilerplate. By changing the declared type on a form from TypedForm<IUserRegistration> to TypedForm<Omit<IUserRegistration, ‘exclude’>>, I am able to dynamically alter what properties my form should and should not concern itself with. Omit allows you to take in an entire interface and then specifically exclude specific properties that are needed for that particular implementation. Pick does the opposite, it provides none of the properties except for the ones included. This is why the omitForm doesn’t use the exclude property and why pickForm uses only the exclude property.
This is an amazingly helpful feature for cleaning up files and eliminating the need for redundant code in the form of: User, IUserRegistration, IUserRegistrationForm, IUserLogin, IUserLoginForm, etc. You can have your base User class and either Pick or Omit everything else.