One of the most basic things that a web application needs to handle is to let the user enter data. A lot has changed on that matter, since the introduction of the form and input tags in the HTML 2.0 specification. We have many input types now. The browser support improved, p.ex. with calendar widgets for entering dates. Web frameworks emerged and some of them also built tooling around forms and inputs.
Angular is a prominent example of that. In fact, there is two ways to handle forms in an Angular way. The first is template-driven forms, the other reactive forms. Today we will have a look at the latter one.
I like reactive forms. The way of defining your forms as a piece of data and the easy way of adding validation, even with my own synchronous or asynchronous validators if necessary, is appealing to me. Angular 14 introduced typed reactive forms. I remember looking forward to this feature, but then it always felt a bit clunky to me when I tried to type reactive forms explicitly.
Let’s have a look at the following piece of code that defines a form for entering personal data.
readonly personForm = new FormGroup({ name: new FormControl('', Validators.required), surname: new FormControl('', Validators.required), age: new FormControl(null, Validators.required), });
If we wanted to explicitly type this form, it would look somewhat like this:
type PersonForm = FormGroup<{ name: FormControl<string | null>; surname: FormControl<string | null>; age: FormControl<number | null>; }>;
We can include that in our form declaration as shown in the following code snippet.
readonly personForm: PersonForm = new FormGroup({ name: new FormControl('', Validators.required), surname: new FormControl('', Validators.required), age: new FormControl(null, Validators.required), });
In my opinion, having to write FormGroup
, FormArray
and FormControl
every time is quite a bit of overhead. Same goes with the null
s (yes, I can use nonNullable
and I do. It’s especially comfortable with the FormBuilder
). The problem gets visible when you have to maintain the software. The bigger the form, the harder and more tiring it gets to read the code. Ideally, I’d prefer to write something like the following as form type:
type Person = { name: string; surname: string; age: number; }; type PersonForm = FormGroupOf<Person>;
For a reason that I will show you later, we will slightly alter the target to get to the following code.
type PersonForm = FormOf<Person>; readonly personForm = new FormGroup<PersonForm>({ name: new FormControl('', Validators.required), surname: new FormControl('', Validators.required), age: new FormControl(null, Validators.required), });
Here we don’t generate the type of the FormGroup
itself, but instead the type of the FormGroup
content. Let’s see how we can accomplish that.
Simple one-dimensional forms
We can use TypeScript code to generate types from other types. In the first version, we will iterate over every key of our Person
object type (or any other one-level deep object type) and create FormControl
types for its properties. The following code can achieve that.
type FormOf<Base> = { [Key in keyof Base]: FormControl<Base[Key] | null> };
That is enough type our initial form. But forms might be more complicated.
Adding arrays to the form
What if we want to track a persons hobbies as well. We add an array of strings to our person to do so.
type Person = { name: string; surname: string; age: number; hobbies: string[]; };
What do we expect our form control type to be? At the moment our TypeScript code would generate our hobbies as hobbies: FormControl<string[] | null>
, which might be perfect if we use a multi select input that requires this type. But let’s assume we want to manage the hobbies by using a FormArray<FormControl<string | null>>
. A type like this usually means that we have one input field per hobby and we add more input fields if we want to add more hobbies. We need to adjust our FormOf
type as follows to support this.
type FormOf<Base> = { [Key in keyof Base]: Base[Key] extends Array<infer ArrT> ? FormArray<FormControl<ArrT>> : FormControl<Base[Key] | null> };
We can use extends
to check the type of the property (Base[Key]
). The Array
is generic and needs to be typed for this check. Thankfully, Typescript supports type inference using infer
. We use the inferred type to type the FormControl
in the FormArray
.
How will this look in our form declaration? This is shown in the following code, where there are no initial hobbies. The this.fb.array
call needs to be typed, because it can not take the type from the outer FormGroup
.
readonly personForm = new FormGroup<PersonForm>({ // ... hobbies: this.fb.array<FormControl<string>>([]), });
Adding objects
Let’s take it another step further and add objects. In our example, we add an address to the user as shown in the following code block.
type Address = { street: string; no: string; zip: string; city: string; country: string; }; type Person = { name: string; surname: string; age: number; hobbies: string[]; address: Address; };
My expectation is that we have another FormGroup
inside of the personForm
FormGroup
. We achieve this by adding a check for object to the FormOf
type. We can add the child FormGroup
in a recursive way, reusing our FormOf
type, so that we can use all the handling that we defined already for any child object in our Base
type.
type FormOf<Base> = { [Key in keyof Base]: Base[Key] extends Array<infer ArrT> ? FormArray<FormControl<ArrT | null>> : Base[Key] extends object ? FormGroup<FormOf<Base[Key]>> : FormControl<Base[Key] | null> };
Now we can add the address to our form and we also see why it is useful to have FormOf
instead of FormGroupOf
: We can easily define a separate form type for the address and use it in the inner this.fb.group
call. It is necessary to type every inner FormGroup
separately, because the type can’t be inferred from the outer FormGroup
.
type PersonForm = FormOf<Person>; type AddressForm = FormOf<Address>; // ... readonly personFormGroup = this.fb.group<PersonForm>({ // ... address: this.fb.group<AddressForm>({ street: this.fb.control(null, Validators.required), no: this.fb.control(null, Validators.required), zip: this.fb.control(null, Validators.required), city: this.fb.control(null, Validators.required), country: this.fb.control(null, Validators.required), }), });
Array of objects
So far we can handle already a lot, but we are not quite finished. Let’s assume, we want to track our hobbies with some more details, like the year when we started the hobby. We create a new Hobby
type and use that in our hobbies
property instead of string. The types look like this.
type Hobby = { name: string; sinceYear: number; }; type Person = { // ... hobbies: Hobby[]; // ... };
To support a resulting FormArray
of FormGroup
s we need to extend our array check in FormOf
. It’s not enough to use the inferred array type ArrT
, we also need to check whether ArrT
is an object or not.
type FormOf<Base> = { [Key in keyof Base]: Base[Key] extends Array<infer ArrT> ? FormArray<ArrT extends object ? FormGroup<FormOf<ArrT>> : FormControl<ArrT | null> > : Base[Key] extends object ? FormGroup<FormOf<Base[Key]>> : FormControl<Base[Key] | null> };
If ArrT
is an object, we recursively call FormOf
inside of a FormGroup
, similar to the handling of Base[Key]
.
The resulting adjustment of the personFormGroup
looks as follows:
type PersonForm = FormOf<Person>; type AddressForm = FormOf<Address>; type HobbyFormGroup = FormOf<Hobby>; // ... readonly personFormGroup = this.fb.group<PersonForm>({ // ... hobbies: this.fb.array<FormGroup<HobbyForm>>([]), // ... });
Array of arrays
The final structural case that we need to consider is having arrays of arrays. I don’t have a practical usecase for that, so it will be handled on a theoretical basis.
In any case, the expected result is a FormArray
of FormArray
s. We extend the check for ArrT
and look whether it is a FormArray
. If it is, we need to recurse to the FormArray
case. Therefore, we need to extract the FormArray
case into its own type FormArrayOf
that we can use for the recursion.
type FormArrayOf<Base> = FormArray< Base extends Array<infer ArrT> ? FormArrayOf<ArrT> : Base extends object ? FormGroup<FormOf<Base>> : FormControl<Base | null> >; type FormOf<Base> = { [Key in keyof Base]: Base[Key] extends Array<infer ArrT> ? FormArrayOf<ArrT> : Base[Key] extends object ? FormGroup<FormOf<Base[Key]>> : FormControl<Base[Key] | null> };
That covers all structural topics, but we are not quite finished.
Possibly undefined properties in your form
Address forms usually have something like an optional address line 2 or similar. Let’s extend our Address
with such an optional property.
type Address = { street: string; no: string; addressLine2?: string zip: string; city: string; country: string; };
In its current form, FormOf
will make the field addressLine2
in the FormGroup
optional (addressLine2?: FormControl<string | null>
). That is not what we want. The field needs to exist. Only the filling of the field needs to be optional, which we can achieve by not putting a Validator.required
.
We can add a -?
to the reading of the keys in FormOf
to get rid of the ?
on the property. That doesn’t fully get rid of the undefined
. We need to remove it from our Base[Key]
types or it will break our checks. Let’s create the type Define
for this purpose.
type Define<T> = Exclude<T | undefined>; type FormArrayOf<Base> = FormArray< Base extends Array<infer ArrT> ? FormArrayOf<ArrT> : Base extends object ? FormGroup<FormOf<Base>> : FormControl<Base | null> >; type FormOf<Base> = { [Key in keyof Base]-?: Define<Base[Key]> extends Array<infer ArrT> ? FormArrayOf<ArrT> : Define<Base[Key]> extends object ? FormGroup<FormOf<Define<Base[Key]>>> : FormControl<Base[Key] | null> };
Now addressLine2
in the FormGroup
is not optional anymore. However, undefined didn’t disappear completely. It is in its type FormControl<string | undefined | null>
. We can remove it from there if we use Define
on Base[Key]
in FormControl<Base[Key] | null>
in FormOf
. However, it might be convenient to leave it in its current state to allow setting existing addresses where the field is missing.
Bonus: I don’t like what is generated
During this the setup of FormOf
and FormArrayOf
we made certain assumptions. For instance, in case of a string[]
in our base type, we decided to create a FormArray<FormControl<string>>
. There might be use-cases where we may want to override that, because we have a control that allows us to enter multiple values, p.ex. a multi-select component. The component might require a FormGroup
property of type FormControl<string[]>
instead.
TypeScripts Omit
and &
can help us here, removing the initial type from the form and adding the required type to the generated form type later. Assuming our hobbies are still a string array, we can override the FormArray
with a FormControl
like this:
type Person = { name: string; surname: string; age: number; hobbies: string[]; }; type PersonForm = FormOf<Omit<Person, 'hobbies'>> & { hobbies: FormControl<string[]>}; // ... readonly personForm = new FormGroup<PersonForm>({ // ... hobbies: this.fb.control([]), });
Summary
That’s it. Congratulations for making it to the end. We learned how to create a factory type for Angulars reactive form types, with the most common use-cases involving FormGroup
, FormArray
and FormControl
. At the end we also looked into overriding parts of the generated type if necessary.
This looks very complicated, but it is a one time effort that allows us to create nicely typed forms in all future cases. I find it to be really helpful whenever values need to be read from or written to the form group or its properties. I hope it will be similarly helpful to you.