The Ultimate Angular Form Type

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 FormGroupFormArray and FormControl every time is quite a bit of overhead. Same goes with the nulls (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 FormGroups 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 FormArrays. 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 FormGroupFormArray 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.