Skip to content

A Guide to (Typed) Reactive Forms in Angular - Part II (Building Dynamic Superforms)

In the first blog post of the series, we learned about Angular reactive forms and the data structures behind them. When developing real-world applications, however, you often need to leverage dynamic forms, as writing boilerplate for every form and its specific cases can be tedious and time-consuming. In certain situations, it may even be necessary to retrieve information from an API to construct the forms.

In this post, we will go over a convenient abstraction we can create to build dynamic and adaptable forms without repeating boilerplate. The trick is to create a "view model" for our data and use a service to transform that data into a reactive form. I was first introduced to this approach by my friend and former teammate Thomas Duft, and I've been using it ever since.

The approach outlined in the linked article worked great with untyped forms, but since now we can get our forms strictly typed, we'll want to upgrade it.

And here is where it gets a bit tricky. Remember how I mentioned you shouldn't predeclare your form groups earlier? If you want to recursively create a form from a config, you just have to. And it's a dynamic form, so you cannot easily type it. To solve the issue, I devised a trick inspired by a "Super Form" suggested by Bobby Galli. Assuming we will have interfaces defined for our data, using this approach, we can create dynamic type-safe forms.

First, we'll create types for our form config:

// this will be our ViewModel for configuring a FormGroup
export class FormSection<
  T extends {
    [K in keyof T]:
      | FormSection<any>
      | FormField<any>
      | (FormSection<any> | FormField<any>)[];
  } = any
> {
  public title?: string;
  public fields: T;

  constructor(section: {
    title?: string;
    fields: T;
  }) {
    this.title = section.title;
    this.fields = section.fields;
  }
}

// Let's define some editor types we'll be using in the templates later
export type FormEditor =
  | 'textInput'
  | 'passwordInput'
  | 'textarea'
  | 'checkbox'
  | 'select';

// And this will be a ViewModel for our FormControls
export class FormField<T> {
  public value: T;
  public editor: FormEditor;
  public validators: Validators;
  public label: string;
  public required: boolean;
  public options?: T[];

  constructor(field: {
    value: T;
    editor: FormEditor;
    validators: Validators;
    label: string;
    required: boolean;
    options?: T[];
  }) {
    this.value = field.value;
    this.editor = field.editor;
    this.validators = field.validators;
    this.label = field.label;
    this.required = field.required;
    this.options = field.options;
  }
}

And then we'll create some type mappings:

// We will use this type mapping to properly declare our form group
export type ControlsOf<T extends Record<string, any>> = {
  [K in keyof T]: T[K] extends Array<any>
    ? FormArray<AbstractControl<T[K][0]>>
    : T[K] extends Record<any, any>
    ? FormGroup<ControlsOf<T[K]>>
    : FormControl<T[K] | null>;
};

// We will use this type mapping to type our form config
export type ConfigOf<T> = {
  [K in keyof T]: T[K] extends (infer U)[]
    ? U extends Record<any, any>
      ? FormSection<ConfigOf<U>>[]
      : FormField<U>[]
    : T[K] extends Record<any, any>
    ? FormSection<ConfigOf<T[K]>>
    : FormField<T[K]>;
};

And now we can use our types in a service that will take care of creating nested dynamic forms:

import { Injectable } from '@angular/core';
import {
  AbstractControl,
  FormArray,
  FormControl,
  FormGroup,
} from '@angular/forms';
import { ConfigOf, ControlsOf, FormField, FormSection } from './forms.model';

@Injectable({
  providedIn: 'root',
})
export class FormsService {
  public createFormGroup<T extends Record<string, any>>(
    section: FormSection<ConfigOf<T>>
  ): FormGroup<ControlsOf<T>> {
    // we need to create an empty FormGroup first, so we can add FormControls recursively
    const group = new FormGroup({});

    Object.keys(section.fields).forEach((key: any) => {
      const field = section.fields[key];
      if (Array.isArray(field)) {
        group.addControl(key, this.createFormArray(field));
      } else {
        if (field instanceof FormSection) {
          group.addControl(key, this.createFormGroup(field));
        } else {
          group.addControl(key, new FormControl(field.value, field.validators));
        }
      }
    });

    // and we need to cast the group to the correct type before returning
    return group as unknown as FormGroup<ControlsOf<T>>;
  }

  public createFormArray<T extends Record<string, any>>(
    fields: unknown[]
  ): FormArray<AbstractControl<T>> {
    const array: FormArray<AbstractControl<any>> = new FormArray(
      []
    ) as unknown as FormArray<AbstractControl<T>>;

    fields.forEach((field) => {
      if (field instanceof FormSection) {
        array.push(this.createFormGroup(field));
      } else {
        const { value, validators } = field as FormField<T>;
        array.push(new FormControl(value, validators));
      }
    });

    return array as unknown as FormArray<AbstractControl<T>>;
  }
}

And that's it. Now we can use our FormService to create forms. Let's say we have the following User model:

export type User = {
  email: string;
  name: string;
}

We can create a form for this user from config in the following way:

  const userFormConfig = new FormSection<ConfigOf<User>>({
      title: 'User Form',
      fields: {
        email: new FormField<string>({
          value: '',
          validators: [Validators.required, Validators.email],
          label: 'Email',
          editor: 'textInput',
          required: true,
        }),
        name: new FormField<string>({
          value: '',
          validators: [Validators.required],
          label: 'Name',
          editor: 'textInput',
          required: true,
        })
      }
  });

  const userForm = this.formsService.createFormGroup<User>(userFormConfig);

If we would check the type of userForm.value now, we would see that it's correctly inferred as:

Partial<{
    email: string | null;
    name: string | null;
}>

Outputting the Dynamic Forms

To display the dynamic forms, we can write a simple component that takes the FormSection or FormField as an Input() along with our FormGroup and displays the form recursively.

We can use a setter to assign either field or section property when the view model is passed into the component, so we can conveniently use them in our template. Our form component's TypeScript code will look something like this:

import { Component, Input } from '@angular/core';
import { FormField, FormSection } from '../forms.model';
import { FormArray, FormGroup } from '@angular/forms';

@Component({
  selector: 'app-form',
  templateUrl: './form.component.html',
  styleUrls: ['./form.component.scss'],
})
export class FormComponent {
  private fieldConfig?: FormField<any>;
  private sectionConfig?: FormSection<any>;
  private arrayConfig?: (FormSection<any> | FormField<any>)[];
  private sectionFieldsArray?: [string, FormField<any>][];

  @Input()
  public set config(
    config:
      | FormField<any>
      | FormSection<any>
      | (FormSection<any> | FormField<any>)[]
  ) {
    this.fieldConfig = config instanceof FormField ? config : undefined;
    this.arrayConfig = Array.isArray(config) ? config : undefined;
    this.sectionConfig = config instanceof FormSection ? config : undefined;

    this.sectionFieldsArray = Object.entries(this.sectionConfig?.fields || {});
  }

  public get sectionFields(): [string, FormField<any>][] {
    return this.sectionFieldsArray || [];
  }

  public get field(): FormField<any> | undefined {
    return this.fieldConfig;
  }

  public get section(): FormSection<any> | undefined {
    return this.sectionConfig;
  }

  public get array(): (FormSection<any> | FormField<any>)[] | undefined {
    return this.arrayConfig;
  }

  ngAfterViewInit() {
    console.log(this.arrayConfig);
  }

  @Input()
  public key!: string;

  @Input()
  public group!: FormGroup;

  public get sectionGroup(): FormGroup {
    return this.group.get(this.key) as FormGroup;
  }

  public get formArray(): FormArray {
    return this.group.get(this.key) as FormArray;
  }
}

And our template will reference a new form component for each section field in case we have passed in a FormSection and it will have a switch case to display the correct control in case a FormField has been passed in:

<ng-container *ngIf="field">
  <label>{{ field.label }}</label>
  <div [ngSwitch]="field.editor" [formGroup]="group">
    <textarea *ngSwitchCase="'textarea'" [formControlName]="key"></textarea>
    <input
      *ngSwitchCase="'passwordInput'"
      [formControlName]="key"
      type="input"
    />
    <input *ngSwitchCase="'checkbox'" [formControlName]="key" type="checkbox" />
    <input *ngSwitchDefault [formControlName]="key" type="text" />
  </div>
</ng-container>

<ng-container *ngIf="section">
  <div>
    <h3 *ngIf="section?.title">{{ section.title }}</h3>
    <app-form
      *ngFor="let sectionField of sectionFields"
      [config]="sectionField[1]"
      [key]="sectionField[0]"
      [group]="key ? sectionGroup : group"
    ></app-form>
  </div>
</ng-container>

<ng-container *ngIf="array">
  <div [formGroup]="group">
    <div [formArrayName]="key">
      <div *ngFor="let item of array; let i = index">
        <app-form
          [config]="item"
          [key]="i.toString()"
          [group]="sectionGroup"
        ></app-form>
      </div>
    </div>
  </div>
</ng-container>

That way, we can display the whole form just by referencing one component, such as

 <app-form [config]="formViewModel" [group]="form"></app-form>

Check out an example on StackBlitz.

In the next (and last) post of the series, we will learn about building custom Form Controls.