Type-safe model-form binding with Angular

A simple first step towards more type-safe forms with Angular (no dependencies required)

ยท

4 min read

So I'm learning Angular right now and I absolutely love TypeScript but I don't like how the Angular Forms API uses and promotes the use of magic strings. If you're like me and love static typing and the safeties it provides, then read on to find out one way you can make working with forms in Angular a bit safer by just utilizing some simple TypeScript language features.

Before that, just a minor disclaimer. I don't hate JavaScript or the way the Angular Forms API is designed. I just prefer stronger type safeties when possible and when it is possible but it is not really shown how to do it, I tend to frown. I guess this allows for more flexibility, and TypeScript is a structural typing system over JavaScript after all, which makes it easy to apply very complex type restrictions over existing JavaScript code.

So without searching for libraries or other people's solutions, I came up with a simple solution on my own using TypeScript typings that covers both the form construction and the bindings to the component template.

I found that the easiest way to make this code reusable is to declare everything in the same file as the data model. So say you have the following model file called IRequestToCreateNewPartner.ts:

import {FormControl, FormGroup} from '@angular/forms';
import {IRequestToCreateNewAddress} from './IRequestToCreateNewAddress';

export interface IRequestToCreateNewPartner {
  id: string;
  nume: string;
  requestToCreateNewAddress: IRequestToCreateNewAddress;
}

Notice that there is a secondary model embedded in this model (an IRequestToCreateNewAddress). The same things we're going to show here can be applied to that model as well.

Next we create a few types to help us out later when we create the form based on this model and when we bind that form to a template. So we add the following to our IRequestToCreateNewPartner.ts file:

/**
 * The type of an object that defines Angular form controls for properties of `{@link IRequestToCreateNewPartner}`.
 */
export type RequestToCreateNewPartnerFormControls = { [key in keyof IRequestToCreateNewPartner]: FormControl | FormGroup };

/**
 * The type of an object that maps the properties of {@link IRequestToCreateNewPartner} to strings.
 * This can be used to access the property names as strings, for use with string-based APIs.
 */
export type IRequestToCreateNewPartnerKeysMap = { [key in keyof IRequestToCreateNewPartner]: key };

/**
 * A object that maps the property names of {@link IRequestToCreateNewPartner} to strings for use with string-based APIs.
 */
export const requestToCreateNewPartnerKeysMap: IRequestToCreateNewPartnerKeysMap = {
  id: 'id',
  nume: 'nume',
  requestToCreateNewAddress: 'requestToCreateNewAddress',
};

The RequestToCreateNewPartnerFormControls type is a type compatible with the FormGroup constructor. It defines the form controls for the IRequestToCreateNewPartner model and limits the valid key names to the property names of the model's type. Notice that the keys are mapped to either a FormControl or another FormGroup in order to supported nested forms (for models that contain other models).

The IRequestToCreateNewPartnerKeysMap type is just a type that maps the model's keys (which are strings) to the same key names! So effectively it's the type of an object with the same property names as the model but with the values of those properties replaced with strings equal to the property names!

Lastly the requestToCreateNewPartnerKeysMap constant is a reusable object with the IRequestToCreateNewPartnerKeysMap type. This object can be used to refer to the model's keys as strings. These string properties can then be referred to in the component template to obtain a type-safe way to refer to the form control names without worrying about mistyping these strings like you would if you were writing literal strings in the template.

This greatly helps with code refactoring as well. The TypeScript compiler will throw an error if the requestToCreateNewPartnerKeysMap object does not conform to the correct form defined by the IRequestToCreateNewPartnerKeysMap type. So even if you later modify the model interface and forget to modify the requestToCreateNewPartnerKeysMap constant accordingly, TypeScript will let you know!

All that's left to do is build the component:

@Component({
  selector: 'app-partner-request-form',
  templateUrl: './partner-request-form.component.html',
  styleUrls: ['./partner-request-form.component.scss'],
})
export class PartnerRequestFormComponent {
  @Input() request?: IRequestToCreateNewPartner | null = null;
  @Output() formSubmit: EventEmitter<IRequestToCreateNewPartner> = new EventEmitter<IRequestToCreateNewPartner>();

  readonly partnerControlNames = requestToCreateNewPartnerKeysMap;
  readonly addressControlNames = requestToCreateNewAddressKeysMap;

  readonly requestToCreateNewAddressFormControls: RequestToCreateNewAddressFormControls = {
    id: new FormControl(this.request?.requestToCreateNewAddress.id),
    apartment: new FormControl(this.request?.requestToCreateNewAddress.apartment)
    // ...
  };

  readonly requestToCreateNewPartnerFormControls: RequestToCreateNewPartnerFormControls = {
    id: new FormControl(this.request?.id),
    nume: new FormControl(this.request?.nume, [Validators.required, Validators.minLength(3)]),
    requestToCreateNewAddress: new FormGroup(this.requestToCreateNewAddressFormControls)
  }

  form = new FormGroup(this.requestToCreateNewPartnerFormControls);

  onSubmit() {
    this.formSubmit.emit(this.form.value);
  }

And the type-safe form template:

<form (ngSubmit)="onSubmit()" [formGroup]="form" novalidate>
  <input type="text" [formControlName]="partnerControlNames.nume"></input>
  <!-- etc., now the nested form: -->
  <div [formGroupName]="partnerControlNames.requestToCreateNewAddress">
      <input type="text" [formControlName]="addressControlNames.apartment"></input>
      <!-- etc. -->
  </div>
</form>

I hope this helps beginners like me and if you have any ideas on how to improve this, please leave a comment.

ย