Search code examples
angulartypescriptangular-reactive-formsunion-typestype-narrowing

Narrowing a union of strictly typed forms in Angular


I have a form which has three separate instances, but at its core has the same data with one or two different properties. I wanted to have a base form class which takes a generic argument to narrow the types and remove some controls where they are not needed, but I have been fighting Typescript for hours and am close to giving up and just using untyped forms.

I have created a smaller example to show the problem:

export type FormOne = {
  name: AbstractControl<string | null>;
  email: AbstractControl<string | null>;
  age: AbstractControl<number | null>;
};

export type FormTwo = {
  name: AbstractControl<string | null>;
  email: AbstractControl<string | null>;
};

export type FormThree = {
  name: AbstractControl<string | null>;
  email: AbstractControl<string | null>;
  location: AbstractControl<string | null>;
};

export type BaseForm = FormOne | FormTwo | FormThree;

I wanted to define my base class like so:

@Directive()
export abstract class BaseFormComponent<T extends BaseForm> {
  private readonly _formBuilder = inject(FormBuilder);

  public readonly baseForm = this._formBuilder.group<T>({
    name: this._formBuilder.control<string | null>(''),
    email: this._formBuilder.control<string | null>(''),
    age: this._formBuilder.control<number | null>(0),
    location: this._formBuilder.control<string | null>(''),
  });
}

but come across the TS2345 error:

TS2345: Argument of type

{
  name: FormControl<string | null>;
  email: FormControl<string | null>;
  age: FormControl<number | null>;
  location: FormControl<string | null>; 
}

is not assignable to parameter of type T

{
  name: FormControl<string | null>;
  email: FormControl<string | null>;
  age: FormControl<number | null>;
  location: FormControl<string | null>;
}

is assignable to the constraint of type T, but T could 
be instantiated with a different subtype of constraint BaseForm.

This confuses me because there are no other subtypes of BaseForm than the three I have defined, and my understanding of union types is that BaseForm would resolve to:

{
  name: AbstractControl<string | null>;
  email: AbstractControl<string | null>;
  age?: AbstractControl<number | null>;
  location?: AbstractControl<string | null>;
}

which should allow the base form as I defined it in the abstract class. The end goal would be to have components like:

@Component({...})
export class FormTwoComponent extends BaseFormComponent<FormTwo> implements OnInit {
  public ngOnInit(): void {
    this.baseForm.removeControl('age');
    this.baseForm.removeControl('location');
  }
}

Now I realise that the component above has a form which doesn't conform to the given T until ngOnInit runs, and so this might cause compile-time problems of its own, but I haven't even got so far as defining the base class.

Can anyone more versed in Typescript explain to me how I am going wrong, and how this could be done, if at all?

StackBlitz project containing all code shown.


Solution

  • To anyone coming across this, I am not sure it is possible, nor would it achieve what I wanted originally.

    What I settled on was a custom form builder, which, while ugly, does allow me to create a typed form with certain properties on some forms and not on others.

    Here is an example of the code:

    export class TypedFormBuilder<T extends Record<string, any> = {}> {
      private readonly _formBuilder = inject(FormBuilder);
      private readonly _form: FormGroup = this._formBuilder.group({}) as unknown as FormGroup<T>;
    
      public withName(name?: string) {
        this._form.addControl('name', this._formBuilder.control(name));
    
        return this as unknown as TypedFormBuilder<T & { name: string }>;
      }
    
      public withAge(age?: number) {
        this._form.addControl('age', this._formBuilder.control(age));
    
        return this as unknown as TypedFormBuilder<T & { age: number }>;
      }
    
      public build(): FormControl<T> {
        return this._form;
      }
    }
    
    const builder = new TypedFormBuilder(); // TypedFormBuilder<{}>
    
    const form = builder 
      .withName('John') // TypedFormBuilder<{name: string}>
      .withAge(32) // TypedFormBuilder<{name: string} & {age: number}>
      .build(); //FormGroup<{name: string} & {age: number}>
    
    form.controls. // intellisense suggests the controls
    
    form.value // {name: 'John', age: 32}
    
    

    You can create just one generic withControl method and still get the typed form:

    public withControl<K extends string, V, NN extends boolean = false>(
      key: K,
      value: V,
      options: { nonNullable?: NN, validators?: ValidatorFn[] } = {}
    ) {
      const { nonNullable, validators } = options;
    
      this._form.addControl(key, this._formBuilder.control(value, {
        nonNullable,
        validators
      }));
    
      return this as unknown as TypedFormBuilder<T & { [key in K]: NN extends true ? V : V | null }>;
    }
    
    const form = builder
      .withControl('name', 'John', { nonNullable: true })
      .withControl('age', 32, { validators: [Validators.max(99)] })
      .build(); // FormGroup<{name: string} & {age: number | null}>
    

    but personally I prefer the explicit withName, withAge, etc. as a fair few of my controls will need custom validators.

    Some may call this lunacy, some may call it cheating because I have to use unknown, but I don't care :)