Skip to content

Валидация форм в Angular

1. Встроенные валидаторы

В Reactive Forms

typescript
import { Validators } from '@angular/forms';

this.form = this.fb.group({
  name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
  email: ['', [Validators.required, Validators.email]],
  age: [null, [Validators.required, Validators.min(18), Validators.max(120)]],
  website: ['', Validators.pattern(/^https?:\/\/.+/)],
});

В Template-driven Forms

html
<input name="name"
       [(ngModel)]="user.name"
       required
       minlength="2"
       maxlength="50">

<input name="email"
       [(ngModel)]="user.email"
       required
       email>

<input name="age"
       [(ngModel)]="user.age"
       required
       type="number"
       min="18"
       max="120">

<input name="website"
       [(ngModel)]="user.website"
       pattern="^https?://.+">

Список встроенных валидаторов

ВалидаторReactiveTemplateОписание
requiredValidators.requiredrequiredПоле обязательно
requiredTrueValidators.requiredTrueЗначение должно быть true
emailValidators.emailemailФормат email
minLengthValidators.minLength(n)minlength="n"Минимальная длина строки
maxLengthValidators.maxLength(n)maxlength="n"Максимальная длина строки
minValidators.min(n)min="n"Минимальное числовое значение
maxValidators.max(n)max="n"Максимальное числовое значение
patternValidators.pattern(regex)pattern="regex"Соответствие регулярному выражению

2. Отображение ошибок

Reactive Forms

typescript
@Component({
  selector: 'app-register',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <div>
        <label>Email:</label>
        <input formControlName="email">
        <div *ngIf="form.get('email')?.invalid && form.get('email')?.touched"
             class="error">
          <small *ngIf="form.get('email')?.hasError('required')">
            Email обязателен
          </small>
          <small *ngIf="form.get('email')?.hasError('email')">
            Неверный формат email
          </small>
        </div>
      </div>

      <div>
        <label>Пароль:</label>
        <input formControlName="password" type="password">
        <div *ngIf="form.get('password')?.invalid && form.get('password')?.touched"
             class="error">
          <small *ngIf="form.get('password')?.hasError('required')">
            Пароль обязателен
          </small>
          <small *ngIf="form.get('password')?.hasError('minlength')">
            Минимум {{ form.get('password')?.getError('minlength').requiredLength }} символов
            (сейчас {{ form.get('password')?.getError('minlength').actualLength }})
          </small>
        </div>
      </div>

      <button [disabled]="form.invalid">Зарегистрироваться</button>
    </form>
  `
})
export class RegisterComponent {
  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]]
  });

  constructor(private fb: FormBuilder) {}

  // Удобный геттер для сокращения шаблона
  get email() { return this.form.get('email'); }
  get password() { return this.form.get('password'); }

  onSubmit() {
    if (this.form.valid) {
      console.log(this.form.value);
    } else {
      // Пометить все поля как touched для показа ошибок
      this.form.markAllAsTouched();
    }
  }
}

3. Custom Validators

Синхронный валидатор

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

// Функция-фабрика валидатора
export function forbiddenNameValidator(forbidden: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const isForbidden = control.value === forbidden;
    return isForbidden ? { forbiddenName: { value: control.value } } : null;
  };
}

// Простой валидатор без параметров
export function noWhitespaceValidator(control: AbstractControl): ValidationErrors | null {
  const hasWhitespace = (control.value || '').trim().length === 0;
  return hasWhitespace ? { whitespace: true } : null;
}

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

typescript
this.form = this.fb.group({
  username: ['', [Validators.required, forbiddenNameValidator('admin'), noWhitespaceValidator]],
});
html
<div *ngIf="form.get('username')?.hasError('forbiddenName')">
  Имя "{{ form.get('username')?.getError('forbiddenName').value }}" запрещено
</div>
<div *ngIf="form.get('username')?.hasError('whitespace')">
  Имя не может быть пустым
</div>

Валидатор-директива (для template-driven форм)

typescript
@Directive({
  selector: '[appForbiddenName]',
  providers: [{
    provide: NG_VALIDATORS,
    useExisting: ForbiddenNameDirective,
    multi: true
  }]
})
export class ForbiddenNameDirective implements Validator {
  @Input('appForbiddenName') forbiddenName = '';

  validate(control: AbstractControl): ValidationErrors | null {
    return this.forbiddenName
      ? forbiddenNameValidator(this.forbiddenName)(control)
      : null;
  }
}
html
<input name="username"
       [(ngModel)]="user.name"
       appForbiddenName="admin">

Cross-field валидатор (валидация группы)

typescript
export function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;
  return password === confirm ? null : { passwordMismatch: true };
}
typescript
this.form = this.fb.group({
  password: ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['', Validators.required]
}, {
  validators: passwordMatchValidator
});
html
<div *ngIf="form.hasError('passwordMismatch')">
  Пароли не совпадают
</div>

4. Async Validators

Асинхронные валидаторы используются для проверок, требующих обращения к серверу (уникальность email, логина и т.д.).

typescript
import { AsyncValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms';
import { Observable, of, timer } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class UsernameValidator {
  constructor(private userService: UserService) {}

  usernameExists(): AsyncValidatorFn {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
      if (!control.value) {
        return of(null);
      }

      // debounce 300ms перед запросом
      return timer(300).pipe(
        switchMap(() => this.userService.checkUsername(control.value)),
        map(exists => exists ? { usernameTaken: true } : null),
        catchError(() => of(null))
      );
    };
  }
}

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

typescript
@Component({ /* ... */ })
export class RegisterComponent {
  form = this.fb.group({
    username: ['',
      [Validators.required, Validators.minLength(3)],  // синхронные
      [this.usernameValidator.usernameExists()]          // асинхронные
    ]
  });

  constructor(
    private fb: FormBuilder,
    private usernameValidator: UsernameValidator
  ) {}
}
html
<input formControlName="username">
<div *ngIf="form.get('username')?.pending">Проверка...</div>
<div *ngIf="form.get('username')?.hasError('usernameTaken')">
  Это имя уже занято
</div>

Статус PENDING

Пока асинхронный валидатор работает, поле имеет статус PENDING:

html
<button [disabled]="form.invalid || form.pending">Отправить</button>

5. Dynamic Forms

Динамическое создание формы на основе конфигурации.

typescript
interface FormFieldConfig {
  key: string;
  label: string;
  type: 'text' | 'email' | 'number' | 'select' | 'checkbox';
  required?: boolean;
  options?: { value: string; label: string }[];
}

@Component({
  selector: 'app-dynamic-form',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <div *ngFor="let field of fields">
        <label>{{ field.label }}:</label>

        <ng-container [ngSwitch]="field.type">
          <input *ngSwitchCase="'text'"
                 [formControlName]="field.key"
                 type="text">

          <input *ngSwitchCase="'email'"
                 [formControlName]="field.key"
                 type="email">

          <input *ngSwitchCase="'number'"
                 [formControlName]="field.key"
                 type="number">

          <select *ngSwitchCase="'select'" [formControlName]="field.key">
            <option value="">-- Выберите --</option>
            <option *ngFor="let opt of field.options" [value]="opt.value">
              {{ opt.label }}
            </option>
          </select>

          <label *ngSwitchCase="'checkbox'">
            <input type="checkbox" [formControlName]="field.key">
          </label>
        </ng-container>

        <div *ngIf="form.get(field.key)?.invalid && form.get(field.key)?.touched"
             class="error">
          Поле обязательно
        </div>
      </div>

      <button type="submit" [disabled]="form.invalid">Отправить</button>
    </form>
  `
})
export class DynamicFormComponent implements OnInit {
  fields: FormFieldConfig[] = [
    { key: 'name', label: 'Имя', type: 'text', required: true },
    { key: 'email', label: 'Email', type: 'email', required: true },
    { key: 'age', label: 'Возраст', type: 'number' },
    { key: 'role', label: 'Роль', type: 'select', required: true,
      options: [
        { value: 'dev', label: 'Разработчик' },
        { value: 'designer', label: 'Дизайнер' },
        { value: 'pm', label: 'Менеджер' }
      ]
    },
    { key: 'agree', label: 'Согласие', type: 'checkbox', required: true }
  ];

  form!: FormGroup;

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    const group: Record<string, any> = {};
    for (const field of this.fields) {
      const validators = field.required ? [Validators.required] : [];
      if (field.type === 'email') validators.push(Validators.email);
      group[field.key] = ['', validators];
    }
    this.form = this.fb.group(group);
  }

  onSubmit() {
    console.log(this.form.value);
  }
}

6. Обработка отправки формы

Полный пример с обработкой ошибок

typescript
@Component({
  selector: 'app-contact',
  template: `
    <form [formGroup]="contactForm" (ngSubmit)="onSubmit()">
      <input formControlName="name" placeholder="Имя">
      <input formControlName="email" placeholder="Email">
      <textarea formControlName="message" placeholder="Сообщение"></textarea>

      <div *ngIf="submitError" class="error">{{ submitError }}</div>
      <div *ngIf="submitSuccess" class="success">Сообщение отправлено!</div>

      <button type="submit" [disabled]="isSubmitting">
        {{ isSubmitting ? 'Отправка...' : 'Отправить' }}
      </button>
    </form>
  `
})
export class ContactComponent {
  contactForm = this.fb.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]],
    message: ['', [Validators.required, Validators.minLength(10)]]
  });

  isSubmitting = false;
  submitError = '';
  submitSuccess = false;

  constructor(
    private fb: FormBuilder,
    private contactService: ContactService
  ) {}

  onSubmit() {
    if (this.contactForm.invalid) {
      this.contactForm.markAllAsTouched();
      return;
    }

    this.isSubmitting = true;
    this.submitError = '';
    this.submitSuccess = false;

    this.contactService.send(this.contactForm.value).subscribe({
      next: () => {
        this.submitSuccess = true;
        this.contactForm.reset();
        this.isSubmitting = false;
      },
      error: (err) => {
        this.submitError = 'Ошибка отправки: ' + err.message;
        this.isSubmitting = false;
      }
    });
  }
}

Сводная таблица

ТемаReactive FormsTemplate-driven
Встроенные валидаторыValidators.required и т.д.required, email и т.д.
Custom validatorФункция ValidatorFnДиректива + NG_VALIDATORS
Async validatorAsyncValidatorFn + ObservableДиректива + NG_ASYNC_VALIDATORS
Cross-fieldВалидатор на FormGroupngModelGroup + директива
Ошибкиcontrol.hasError('key')#ref.errors?.['key']
Статусform.statusform.valid / invalid
Dynamic formsFormBuilder + конфигурацияСложно реализовать