Когда несколько лет ждешь официального решения и не пользуешься сторонними библиотеками
Когда несколько лет ждешь официального решения и не пользуешься сторонними библиотеками

Всем привет! В июне 2022 года наша фронтенд команда наконец-то дождалась строго типизированных форм от разработчиков Angular! Через какое-то время мы заметили что не все работает так, как интуитивно ожидаешь. Позднее я создал небольшой внутренний документ с “фишками”, которые явно не описаны в официальных доках. И подумал: “А почему бы мне не выложить наш небольшой гайд на Хабр?”. И вот выкладываю.

Нам в команде всегда не хватало этой функциональности, но мы решили не использовать сторонние решения, такие как @rxweb/types, чтобы избежать их собственных багов и не патчить ключевой функционал Ангулара без крайней необходимости.

Суть проблемы

До релиза версии 14 не было поддержки строгой типизации форм:

export class UserFormComponent implements OnInit {
 editForm: UntypedFormGroup = this.fb.group({
   name: '',
   lastName: '',
   age: 0,
 });

 constructor(private fb: UntypedFormBuilder) {}

 ngOnInit() {
   // Поле firstName не существует в форме но typescript не выдает ошибку
   this.editForm.controls.firstName.patchValue('Jack');
 }
}

На этом примере обращение к полю firstName должно вызывать ошибку компиляции Typescript, но этого не происходит. Обратите внимание, чтобы продемонстрировать проблему предыдущей версии, я использую старые интерфейсы UntypedFormGroup и UntypedFormBuilder. Эти интерфейсы были добавлены в Angular 14 для обратной совместимости. Именно они добавляются ко всем формам при автоматическом обновлении с версии 13 до 14 с помощью ng update.

Типизация форм - это просто!

Теперь возьмем тот же самый код и используем обновленные интерфейсы v14:

export class UserFormComponent implements OnInit {
 editForm = this.fb.group({
   name: '',
   lastName: '',
   age: 0,
 });

 constructor(private fb: FormBuilder) {}

 ngOnInit() {
   this.editForm.controls.firstName.patchValue('Jack'); // ошибка - неправильное имя поля
   this.editForm.controls.age.patchValue('24'); // ошибка - неправильный тип, должен быть number
   this.editForm.controls.lastName.patchValue('Bauer'); // все правильно
 }
}
Пример ошибок
Пример ошибок

Обратите внимание: при объявлении формы мы нигде не указывали типы контролов, они вычисляются автоматически, достаточно просто указать начальные значения. Очень удобно!

Однако есть нюансы…

Если указать тип формы как FormGroup, ошибка перестанет появляться:

export class UserFormComponent implements OnInit {
 editForm: FormGroup = this.fb.group({  // добавили тип FormGroup
   name: '',
   lastName: '',
   age: 0,
 });

 constructor(private fb: FormBuilder) {}

 ngOnInit() {
   this.editForm.controls.firstName.patchValue('Jack'); // нет ошибки, а должна быть
   this.editForm.controls.age.patchValue('24'); // нет ошибки, а должна быть
 }
}

Это происходит потому, что editForm теперь присвоен тип FormGroup<any>. Будьте внимательны!

Следующий момент: если не указать никакого типа и инициализировать форму позднее, например после получения асинхронных данных, авто-выведение типа также не будет работать. Причина схожая - editForm будет иметь тип any. Чтобы это исправить, нужно явно указать тип формы:

type EditForm = FormGroup<{
  name: FormControl<string>;
  lastName: FormControl<string>;
  age: FormControl<number>;
}>;

export class UserFormComponent implements OnInit {
 editForm: EditForm; // если убрать тип EditForm - ошибок не будет!

 constructor(private fb: FormBuilder) {}

 ngOnInit() {
   this.getData().subscribe(() => {
     this.editForm = this.fb.group({
       name: new FormControl<string>(''),
       lastName: new FormControl<string>(''),
       age: 0,
     });
     this.editForm.controls.firstName.patchValue('Jack'); // есть ошибка
     this.editForm.controls.age.patchValue('24'); // есть ошибка
   });
 }

 getData = () => of('some asynchronous data'); // просто для примера
}

Также придется явно указывать тип для вложенных форм или кастомных контролов, например:

// предположим что UserSettings - кастомный контрол
new FormControl<UserSettings>(null);

Кстати, на этом примере внимательный читатель может спросить - почему мы можем присвоить UserSettings типу null? Дело в том, что все форм контролы Ангулара могут принимать null. Это сделано для возможности работы метода reset. Однако если нужно другое поведение можно использовать параметр nonNullable. Тогда при вызове метода reset котрол будет принимать начально заданное значение.

Как изловчиться и не указывать тип для массива

Давайте добавим массив children к нашей форме. Если при инициализации первый элемент массива объявлен - тип можно не указывать:

export class UserFormArrayComponent implements OnInit {
 editForm = this.fb.group({
   name: 'Jack',
   lastName: 'Bauer',
   children: this.fb.array([
     this.fb.group({ name: 'Kim', lastName: 'Bauer' })
   ]),
 });

 constructor(private fb: FormBuilder) {}

 ngOnInit() {
   this.editForm.value.children[0].name; // корректно
   this.editForm.value.children[0].firstName; // ошибка при компиляции
 }
}

Однако на практике, в большинстве случаев массив объявляется пустым и тогда придется прописывать тип полностью:

type ChildForm = FormGroup<{
 name: FormControl<string>;
 lastName: FormControl<string>;
}>;
children: this.fb.array<ChildForm>([]),

Давайте избавляться от “нехорошего” метода

До выхода Angular 14 мы частенько пользовались методом get который возвращает FormControl:

this.editForm.get('firstName').value; // так не безопасно

Однако его использование больше небезопасно. В данном примере typescript не выдаст ошибку даже если firstName не существует. Поэтому правильнее заменить метод get на обращение через controls:

this.editForm.controls.firstName.value;  // а так безопасно

Заключение

Коллеги, спасибо что прочитали мою первую статью! Если вы знаете еще полезные приемы работы с типизированными формами, не указанные здесь, пожалуйста расскажите в комментариях. С удовольствием поделюсь с нашей фронтенд командой и возьму на вооружение.

Комментарии (5)


  1. slavaRomantsov
    11.09.2023 14:06

    Привет! Спасибо за материал! Интересный нюанс, буду знать!


    1. DefeNder93 Автор
      11.09.2023 14:06

      Привет! На здоровье)


  1. shai_hulud
    11.09.2023 14:06

    Почему не объявить тип группы а уже потом ее инициировать?

    interface MyFormControls {
      id: FormControl<string>;
      // ...
    } 
    
    private myForm: FormGroup<MyFormControls>;

    Я вообще использую тип-обертку, который генерит этот тип из модели:

    export type ModelFormControls<ModelT> = {
    	[key in keyof ModelT]: FormControl<ModelT[key]>
    };

    Конечно это примитивный мета-тип, можно через тернарный оператор бахнуть поддержку объектов и массивов.


    1. DefeNder93 Автор
      11.09.2023 14:06

      Почему не объявить тип группы а уже потом ее инициировать?

      Так и делаем) В статье есть пример про явное указание типа формы.

      Хорошее дополнение про тип-обертку для случая если модель совпадает с полями формы.


  1. 0Ld
    11.09.2023 14:06

    Очень интересно. Копирую в сохранялку.