Angular 14 strictly typed reactive forms - How to type FormGroup model using existing interface

Viewed 2825

I'm just playing around with new angular typed forms api and couldn't figure out how to type FormGroup without declaring specific "FormInterface" witch has to match the original one.

Maybe I'm missing something or it's just impossible to do so.

I was able to make thing working correctly (example below), but what I'm not a fan of here is declaration of UserForm interface witch has no reference to User interface and has to be a mirror copy of it but with FormControl<> fields.

Is it possible in case like this to type FormGroup just with User interface?.

Full working example available on stackblitz

user.model.ts

export interface User {
  firstName: string;
  lastName: string;
  email: string;
  age: number | null;
}

user.service.ts

@Injectable()
export class UserService {

  add(user: User): void {
    console.log('Add user', user);
  }

}

app.component.ts

interface UserForm {
  firstName: FormControl<string>;
  lastName: FormControl<string>;
  email: FormControl<string>;
  age: FormControl<number | null>;
}

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  form: FormGroup<UserForm>;

  constructor(private userService: UserService) {}

  ngOnInit(): void {
    this.form = new FormGroup({
      firstName: new FormControl<string>('', { nonNullable: true }),
      lastName: new FormControl('', { nonNullable: true }),
      email: new FormControl('', { nonNullable: true }),
      age: new FormControl<number | null>(null),
    });
  }

  submitForm(): void {
    this.userService.add(this.form.getRawValue());
  }
}
6 Answers

You can try using a definite assignment to your form variable. Below is the code:

form!: FormGroup;

You can also use your declaration to initialize the form controls since you're 'posting' values on the form and not yet updating.

form: FormGroup = new FormGroup({
      firstName: new FormControl<string>('', { nonNullable: true }),
      lastName: new FormControl('', { nonNullable: true }),
      email: new FormControl('', { nonNullable: true }),
      age: new FormControl<number | null>(null),
    });

I'd recommend defining your FormGroup and inferring the User interface from it. Use the following utility types to help you.

export type FormValue<T extends AbstractControl> = T extends AbstractControl<infer TValue, any> ? TValue : never;
export type FormRawValue<T extends AbstractControl> = T extends AbstractControl<any, infer TRawValue> ? TRawValue : never;

Note that the FormValue type will make all the properties optional, because the controls can be disabled. If you don't disable controls or mark disable-able controls optional yourself, the FormRawValue type will help you more. In your case, use the latter.

interface UserFormControls {
  firstName: FormControl<string>;
  lastName: FormControl<string>;
  email: FormControl<string>;
  age: FormControl<number | null>;
}

export type UserForm = FormGroup<UserFormControls>;

export type User = FormRawValue<UserForm>;
// type User = {
//  firstName: string;
//  lastName: string;
//  email: string;
//  age: number | null;
// }

stackblitz

I also ran into this because I always assign my FormGroup later and I was not happy about having to add a ton of boilerplate types on every FormGroup declaration. It felt like I was repeating myself, so what I ended up doing was creating a utility type which has been working well.

export type ModelFormGroup<T> = FormGroup<{
  [K in keyof T]: FormControl<T[K]>;
}>;

This makes it easy to type the FormGroup declarations for the many data models I have. For example:

export interface UserModel {
  email: string;
  password: string;
}

// In a class...
userFormGroup: ModelFormGroup<UserModel>;

ngOnInit() {
  this.userFormGroup = this._formBuilder.nonNullable.group({
    email: [''],
    password: [''],
  });
}

Many times my data models will have more properties than I want the form to have, so I take advantage of TypeScripts utility types Pick, Required, Omit, etc...

export interface UserModel {
  email: string;
  password: string;
  dateAdded: Date;
  dateUpdated: Date;
}

// In a class...
userFormGroup: ModelFormGroup<Pick<UserModel, 'email' | 'password' >>;

ngOnInit() {
  this.userFormGroup = this._formBuilder.nonNullable.group({
    email: [''],
    password: [''],
  });
}

You cannot use User as the type of FormGroup. You can however reference User in UserForm with enums then use UserForm as a type of FormGroup. Check out #72500855 on strongly typed forms with Angular 14 since your question is a potential duplicate of it.

What works fairly well is using type interference with the FormBuilder.

const form = this.formBuilder.group<User>({ /* your form definition */ });

This will create a type-safe form that interferes the controls and their types from the User interface.

E.g.

form.patchValue({ age: "15" }); // shows error because `age` has to be a number
form.patchValue({ password: "myPassword"}); // shows error because `password` does not exist on `User` interface.

What is missing though is an easy type definition of the FormGroup class, e.g. when the assignment with the FormBuilder only happens later. The type definition that Angular uses in the FormBuilder.create is

group<T extends {}>(...): FormGroup<{[K in keyof T]: ɵElement<T[K], null>}>

Which is not something I want to have in my code but maybe a simple helper type can already solve this problem.

export type MyFormGroup<T> = FormGroup<{[K in keyof T]: ɵElement<T[K], null>;}>;
const myForm: MyFormGroup<User>;
// `myForm` will now have the same type as the previously shown from the `FormBuilder.group` call.

I have a solution for this problem i'm wrote some types for that https://github.com/iamguid/ngx-mf .

For example you can do something like that:

We define some model:

enum ContactType {
    Email,
    Telephone,
}

interface IContactModel {
    type: ContactType;
    contact: string;
}

interface IUserModel {
    id: number;
    firstName: string;
    lastName: string;
    nickname: string;
    birthday: Date;
    contacts: IContactModel[];
}

Then we define some magic type like:

Type Form = FormModel<IUserModel, { contacts: ['group'] }>

Then we have type based on our model before form will be init:

FormGroup<{
    firstName: FormControl<string | null>;
    lastName: FormControl<string | null>;
    nickname: FormControl<string | null>;
    birthday: FormControl<Date | null>;
    contacts: FormArray<FormGroup<{
        type: FormControl<ContactType | null>;
        contact: FormControl<string | null>;
    }>>;
}>
Related