Интеграция с Angular

Библиотека валидации Vest строится на концепции декларативных тестовых «сьютов», которые выполняются синхронно или асинхронно и возвращают структурированный результат валидации. В связке с Angular основной задачей становится корректное отображение результатов Vest в модель реактивных форм и механизм Angular Validators.

Ключевая сложность интеграции заключается в различии подходов:

  • Angular ожидает ValidationErrors | null
  • Vest возвращает объект результата с коллекцией ошибок по полям
  • Angular выполняет валидаторы по схеме RxJS-потока значений
  • Vest выполняет «сьют» целиком при каждом запуске

Эти различия требуют создания адаптера между системами.


Базовая модель взаимодействия Vest и Angular Forms

Angular Reactive Forms используют структуру FormGroup, FormControl, FormArray. Каждый контрол может принимать валидаторы синхронного и асинхронного типа.

Vest, в свою очередь, строит проверки следующим образом:

import { suite, test, enforce } from 'vest';

const userValidation = suite((data = {}) => {
  test('email', 'Некорректный email', () => {
    enforce(data.email).isEmail();
  });

  test('password', 'Слишком короткий пароль', () => {
    enforce(data.password).longerThan(6);
  });
});

Результат выполнения содержит:

  • status (valid/invalid)
  • ошибки по полям
  • метаданные тестов

Angular требует преобразования этого результата в формат:

{
  email?: { message: string },
  password?: { message: string }
}

Адаптер Vest → Angular Validator

Основной подход заключается в создании функции-обёртки, которая выполняет сьют Vest и преобразует результат в ValidationErrors.

Базовая реализация синхронного валидатора

import { AbstractControl, ValidationErrors } from '@angular/forms';

export function vestValidator(suite: any) {
  return (control: AbstractControl): ValidationErrors | null => {
    const result = suite(control.value);

    if (result.hasErrors()) {
      return mapVestErrors(result.getErrors());
    }

    return null;
  };
}

Функция mapVestErrors преобразует структуру Vest:

function mapVestErrors(errors: any): ValidationErrors {
  const mapped: ValidationErrors = {};

  Object.keys(errors).forEach((field) => {
    mapped[field] = {
      vestError: errors[field][0],
    };
  });

  return mapped;
}

Интеграция с FormGroup

При работе с группами полей важно передавать весь объект формы в Vest, а не отдельные контролы.

Синхронизация состояния формы

this.form = new FormGroup({
  email: new FormControl(''),
  password: new FormControl(''),
});

Подписка на изменения:

this.form.valueChanges.subscribe((value) => {
  const result = userValidation(value);

  this.applyVestResult(result);
});

Применение ошибок к Angular FormControls

Angular не принимает ошибки напрямую на уровне FormGroup как структурированные поля Vest, поэтому требуется ручное распределение.

private applyVestResult(result: any) {
  Object.keys(this.form.controls).forEach((key) => {
    const control = this.form.get(key);

    if (!control) return;

    const fieldErrors = result.getErrors(key);

    if (fieldErrors) {
      control.setErrors({
        vest: fieldErrors[0],
      });
    } else {
      control.setErrors(null);
    }
  });
}

Асинхронная валидация Vest в Angular

Vest поддерживает асинхронные проверки через test с Promise:

test('username', 'Имя занято', async () => {
  await delay(300);
  enforce(await isUsernameTaken(data.username)).equals(false);
});

Angular требует использование AsyncValidatorFn.

Реализация async-адаптера

import { AbstractControl, AsyncValidatorFn } from '@angular/forms';

export function vestAsyncValidator(suite: any): AsyncValidatorFn {
  return (control: AbstractControl) => {
    return new Promise((resolve) => {
      const result = suite(control.value);

      if (result.hasErrors()) {
        resolve(mapVestErrors(result.getErrors()));
      } else {
        resolve(null);
      }
    });
  };
}

Важно учитывать, что Angular ожидает Observable или Promise, поэтому синхронизация с Vest должна учитывать debounce на уровне формы.


Оптимизация частоты выполнения сьютов

Проблема частых вызовов Vest при каждом valueChanges требует оптимизации.

Используется комбинация RxJS операторов:

this.form.valueChanges
  .pipe(
    debounceTime(300),
    distinctUntilChanged()
  )
  .subscribe((value) => {
    const result = userValidation(value);
    this.applyVestResult(result);
  });

Такой подход снижает нагрузку при сложных сьютах.


Разделение валидации по уровням формы

В больших формах целесообразно разделять проверку:

  • Field-level (FormControl)
  • Group-level (FormGroup)
  • Cross-field validation (например, password confirmation)

Cross-field пример

test('passwordMatch', 'Пароли не совпадают', () => {
  enforce(data.password).equals(data.confirmPassword);
});

В Angular это применяется на уровне FormGroup:

this.form.setErrors({
  vestGroup: 'passwordMismatch',
});

Инкапсуляция Vest в сервис Angular

Для масштабируемых приложений логика обёртки выносится в сервис.

@Injectable({ providedIn: 'root' })
export class VestValidationService {
  run(suite: any, data: any) {
    return suite(data);
  }

  map(result: any) {
    return mapVestErrors(result.getErrors());
  }
}

Использование:

const result = this.vestService.run(userValidation, this.form.value);
this.errors = this.vestService.map(result);

Работа с кастомными Angular валидаторами через Vest

Angular позволяет комбинировать валидаторы:

this.form = new FormGroup({
  email: new FormControl('', [
    Validators.required,
    vestValidator(userValidation)
  ]),
});

Проблема заключается в конфликте приоритетов: Angular встроенные валидаторы могут перезаписывать ошибки Vest. Решение — объединение ошибок:

const existingErrors = control.errors || {};
const vestErrors = mapVestErrors(result.getErrors());

control.setErrors({
  ...existingErrors,
  ...vestErrors,
});

Синхронизация состояния при внешних изменениях

Если данные формы обновляются извне (например, через API), необходимо повторно запускать Vest:

patchUser(data: any) {
  this.form.patchValue(data);
  const result = userValidation(this.form.value);

  this.applyVestResult(result);
}

Это предотвращает рассинхронизацию UI и состояния валидации.


Управление состоянием ошибок в сложных формах

При большом количестве полей возникает необходимость централизованного хранения ошибок.

Подход:

  • хранение результата Vest отдельно
  • отображение через селекторы формы
vestErrors$ = new BehaviorSubject<any>(null);

this.form.valueChanges.subscribe((value) => {
  const result = userValidation(value);
  this.vestErrors$.next(result.getErrors());
});

Поддержка динамических форм

Angular поддерживает динамическое создание контролов, Vest требует адаптации структуры данных.

addField(name: string) {
  this.form.addControl(name, new FormControl(''));
}

Сьют должен учитывать динамические ключи:

test('dynamicField', () => {
  enforce(data[name]).isNotEmpty();
});

Типизация результатов Vest

Для строгой типизации используется обобщённая модель:

type VestFieldErrors<T> = {
  [K in keyof T]?: {
    vestError: string;
  };
};

Это позволяет согласовать Angular FormModel и результат проверки.


Обработка сложных сценариев ошибок

В некоторых случаях Vest возвращает несколько ошибок на одно поле. Angular же хранит одну активную ошибку.

Стратегии обработки:

  • приоритет первой ошибки
  • агрегация сообщений
  • кастомный формат отображения

Пример агрегации:

mapped[field] = {
  vestError: errors[field].join(', '),
};

Контроль производительности при больших формах

При формах с сотнями контролов выполнение полного Vest-сьюта становится узким местом.

Используются подходы:

  • разделение сьютов по секциям формы
  • lazy validation отдельных блоков
  • мемоизация результатов
const memoizedSuite = memoize(userValidation);

Также применяется условное выполнение тестов:

onlyIf(data.email);

Расширение через директивы Angular

Интеграция может быть инкапсулирована в директиву:

@Directive({
  selector: '[vestValidate]',
  providers: [{
    provide: NG_VALIDATORS,
    useExisting: forwardRef(() => VestDirective),
    multi: true,
  }]
})
export class VestDirective implements Validator {
  validate(control: AbstractControl) {
    const result = userValidation(control.value);

    return result.hasErrors()
      ? mapVestErrors(result.getErrors())
      : null;
  }
}

Такой подход позволяет использовать Vest декларативно в шаблонах.


Согласование жизненного цикла Angular и Vest

Angular управляет жизненным циклом компонентов через hooks:

  • ngOnInit
  • ngOnChanges
  • ngOnDestroy

Vest не имеет собственного жизненного цикла, поэтому важно:

  • запускать валидацию после инициализации формы
  • очищать подписки при уничтожении компонента
  • избегать повторного создания сьютов без необходимости
ngOnDestroy() {
  this.sub.unsubscribe();
}

Обработка серверных ошибок вместе с Vest

Часто сервер возвращает ошибки, которые должны отображаться вместе с клиентской валидацией.

this.form.setErrors({
  server: 'Email already exists',
});

При этом Vest-ошибки и серверные объединяются:

control.setErrors({
  ...serverErrors,
  ...vestErrors,
});

Стратегии масштабирования интеграции

В крупных приложениях интеграция Vest и Angular требует архитектурного разделения:

  • слой доменной валидации (Vest)
  • слой адаптации (validators/adapters)
  • слой UI (Angular forms)
  • слой состояния (RxJS/NgRx)

Такое разделение снижает связность и позволяет переиспользовать сьюты вне Angular-контекста, включая Node.js или тестирование.