Перейти к содержимому

22. Валидация форм

Angular предоставляет богатый набор встроенных валидаторов и гибкий механизм для отображения ошибок. Разберём всё от required до кросс-полевых валидаторов.


Все валидаторы живут в классе Validators:

import { Validators } from '@angular/forms';
const form = this.fb.group({
// required — поле обязательно
name: ['', Validators.required],
// minLength / maxLength — длина строки
username: ['', [Validators.minLength(3), Validators.maxLength(20)]],
// email — проверка формата email
email: ['', [Validators.required, Validators.email]],
// pattern — регулярное выражение
phone: ['', Validators.pattern(/^\+7\d{10}$/)],
// min / max — числовые значения
age: [null, [Validators.required, Validators.min(18), Validators.max(120)]],
// requiredTrue — checkbox должен быть отмечен
terms: [false, Validators.requiredTrue],
});

В template-driven формах валидаторы указываются как HTML-атрибуты:

<form #f="ngForm" (ngSubmit)="onSubmit(f)">
<!-- required -->
<input name="name" [(ngModel)]="data.name" required #name="ngModel" />
<!-- minlength и maxlength -->
<input
name="username"
[(ngModel)]="data.username"
minlength="3"
maxlength="20"
#username="ngModel"
/>
<!-- email -->
<input
name="email"
type="email"
[(ngModel)]="data.email"
required
email
#emailField="ngModel"
/>
<!-- pattern -->
<input
name="phone"
[(ngModel)]="data.phone"
pattern="^\+7\d{10}$"
#phone="ngModel"
/>
<!-- min и max (через type="number") -->
<input
name="age"
type="number"
[(ngModel)]="data.age"
min="18"
max="120"
#age="ngModel"
/>
</form>

<div *ngIf="email.invalid && email.touched" class="error-block">
<p *ngIf="email.errors?.['required']">Email обязателен</p>
<p *ngIf="email.errors?.['email']">Неверный формат email</p>
</div>
isFieldInvalid(fieldName: string): boolean {
const field = this.form.get(fieldName);
return !!(field && field.invalid && (field.dirty || field.touched));
}
getFieldError(fieldName: string): string {
const field = this.form.get(fieldName);
if (!field?.errors) return '';
const errors = field.errors;
if (errors['required']) return 'Поле обязательно для заполнения';
if (errors['minlength']) return `Минимум ${errors['minlength'].requiredLength} символов`;
if (errors['maxlength']) return `Максимум ${errors['maxlength'].requiredLength} символов`;
if (errors['email']) return 'Введите корректный email';
if (errors['min']) return `Минимальное значение: ${errors['min'].min}`;
if (errors['max']) return `Максимальное значение: ${errors['max'].max}`;
if (errors['pattern']) return 'Неверный формат';
return 'Ошибка валидации';
}
<input formControlName="email" [class.error]="isFieldInvalid('email')" />
<div *ngIf="isFieldInvalid('email')" class="error">
{{ getFieldError('email') }}
</div>
@Pipe({ name: 'validationError', standalone: true })
export class ValidationErrorPipe implements PipeTransform {
transform(errors: ValidationErrors | null): string {
if (!errors) return '';
const [key, value] = Object.entries(errors)[0];
const messages: Record<string, string> = {
required: 'Обязательное поле',
email: 'Неверный email',
minlength: `Минимум ${value.requiredLength} символов`,
maxlength: `Максимум ${value.requiredLength} символов`,
min: `Минимум ${value.min}`,
max: `Максимум ${value.max}`,
pattern: 'Неверный формат',
};
return messages[key] ?? 'Ошибка';
}
}
<div *ngIf="form.get('email')?.errors && form.get('email')?.touched">
{{ form.get('email')?.errors | validationError }}
</div>

FormControl, FormGroup и FormArray наследуют AbstractControl. Его свойства:

const ctrl = this.form.get('email')!;
// Значение
ctrl.value // текущее значение
ctrl.defaultValue // начальное значение
// Состояние
ctrl.valid // true если нет ошибок
ctrl.invalid // true если есть ошибки
ctrl.pending // true во время async валидации
ctrl.disabled // true если поле отключено
ctrl.enabled // обратное disabled
// Взаимодействие
ctrl.dirty // true если значение изменялось
ctrl.pristine // true если значение не изменялось
ctrl.touched // true если был blur
ctrl.untouched // обратное touched
// Ошибки
ctrl.errors // { required: true } | null
ctrl.hasError('required') // проверка конкретной ошибки
ctrl.getError('minlength') // получить данные ошибки
// Методы
ctrl.markAsTouched()
ctrl.markAsDirty()
ctrl.setErrors({ custom: 'message' })
ctrl.clearValidators()
ctrl.addValidators([Validators.required])
ctrl.updateValueAndValidity()

Ошибки хранятся в объекте — ключ это имя валидатора:

// Для Validators.required
errors = { required: true }
// Для Validators.minLength(3) с value='ab'
errors = {
minlength: {
requiredLength: 3,
actualLength: 2
}
}
// Для Validators.max(100) с value=150
errors = {
max: {
max: 100,
actual: 150
}
}
// Для Validators.pattern
errors = {
pattern: {
requiredPattern: '^\\+7\\d{10}$',
actualValue: '89161234567'
}
}

🔗 Кросс-полевая валидация (пароль = подтверждение)

Заголовок раздела «🔗 Кросс-полевая валидация (пароль = подтверждение)»

Валидатор на уровне FormGroup:

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export const passwordMatchValidator: ValidatorFn = (
group: AbstractControl
): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
if (password !== confirm) {
return { passwordMismatch: true };
}
return null;
};
this.form = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
}, { validators: passwordMatchValidator });
<div *ngIf="form.errors?.['passwordMismatch'] && form.get('confirmPassword')?.touched">
⚠️ Пароли не совпадают
</div>

Чтобы показать ошибки при нажатии Submit (не дожидаясь blur):

onSubmit() {
this.form.markAllAsTouched(); // показывает все ошибки сразу
if (this.form.valid) {
this.submitData(this.form.value);
}
}

// Добавить валидатор к существующему контролу
const phone = this.form.get('phone')!;
phone.addValidators(Validators.required);
phone.updateValueAndValidity(); // пересчитать валидацию
// Заменить валидаторы полностью
phone.setValidators([Validators.required, Validators.pattern(/^\+7/)]);
phone.updateValueAndValidity();
// Убрать валидаторы
phone.clearValidators();
phone.updateValueAndValidity();

export default function FormValidationPlayground() {
const [fields, setFields] = React.useState({
name: '', email: '', phone: '', age: '', password: '', confirm: '', terms: false
});
const [touched, setTouched] = React.useState({});
const [showAll, setShowAll] = React.useState(false);
const touch = (f) => setTouched(p => ({ ...p, [f]: true }));
const change = (f, v) => setFields(p => ({ ...p, [f]: v }));
const validators = {
name: v => {
if (!v) return { required: true };
if (v.length < 2) return { minlength: { requiredLength: 2, actualLength: v.length } };
if (v.length > 30) return { maxlength: { requiredLength: 30 } };
return null;
},
email: v => {
if (!v) return { required: true };
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return { email: true };
return null;
},
phone: v => {
if (!v) return { required: true };
if (!/^\+7\d{10}$/.test(v)) return { pattern: { requiredPattern: '+7XXXXXXXXXX' } };
return null;
},
age: v => {
if (!v) return { required: true };
const n = parseInt(v);
if (n < 18) return { min: { min: 18, actual: n } };
if (n > 120) return { max: { max: 120, actual: n } };
return null;
},
password: v => {
if (!v) return { required: true };
if (v.length < 8) return { minlength: { requiredLength: 8, actualLength: v.length } };
return null;
},
confirm: v => {
if (!v) return { required: true };
return null;
},
terms: v => !v ? { requiredTrue: true } : null,
};
const getErrorMsg = (errs) => {
if (!errs) return null;
const [key, val] = Object.entries(errs)[0];
const msgs = {
required: 'Обязательное поле',
email: 'Неверный формат email',
minlength: `Минимум ${val?.requiredLength} символов (сейчас ${val?.actualLength})`,
maxlength: `Максимум ${val?.requiredLength} символов`,
min: `Минимум ${val?.min} (введено ${val?.actual})`,
max: `Максимум ${val?.max} (введено ${val?.actual})`,
pattern: `Формат: ${val?.requiredPattern}`,
requiredTrue: 'Необходимо подтвердить',
};
return msgs[key] || 'Ошибка';
};
const errors = Object.fromEntries(Object.entries(validators).map(([k, fn]) => [k, fn(fields[k])]));
const passwordMismatch = fields.password && fields.confirm && fields.password !== fields.confirm;
const isValid = Object.values(errors).every(e => !e) && !passwordMismatch;
const handleSubmit = () => {
setShowAll(true);
setTouched(Object.fromEntries(Object.keys(fields).map(k => [k, true])));
};
const isTouched = (f) => touched[f] || showAll;
const inputStyle = (field) => ({
width: '100%', background: '#0f172a', padding: '8px 12px', borderRadius: 6, fontSize: 13,
color: '#e2e8f0', outline: 'none', boxSizing: 'border-box',
border: `1px solid ${!isTouched(field) ? '#334155' : errors[field] ? '#dd0031' : '#22c55e'}`
});
const validatorTypes = [
{ field: 'name', label: 'Имя', validators: ['required', 'minLength(2)', 'maxLength(30)'], type: 'text', placeholder: 'Яша' },
{ field: 'email', label: 'Email', validators: ['required', 'email'], type: 'email', placeholder: '[email protected]' },
{ field: 'phone', label: 'Телефон', validators: ['required', 'pattern(+7XXXXXXXXXX)'], type: 'text', placeholder: '+79161234567' },
{ field: 'age', label: 'Возраст', validators: ['required', 'min(18)', 'max(120)'], type: 'number', placeholder: '25' },
{ field: 'password', label: 'Пароль', validators: ['required', 'minLength(8)'], type: 'password', placeholder: '••••••••' },
{ field: 'confirm', label: 'Подтверждение пароля', validators: ['required', 'passwordMatch'], type: 'password', placeholder: '••••••••' },
];
return (
<div style={{ background: '#0f172a', minHeight: 520, padding: 24, borderRadius: 12, fontFamily: 'system-ui', color: '#e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<span style={{ color: '#dd0031', fontWeight: 700, fontSize: 16 }}>✅ Все типы валидаторов</span>
<span style={{ fontSize: 11, padding: '3px 10px', borderRadius: 20, background: isValid ? '#15803d40' : '#7f1d1d40', color: isValid ? '#22c55e' : '#f87171', border: `1px solid ${isValid ? '#22c55e' : '#f87171'}` }}>
{isValid ? '✓ VALID' : '✗ INVALID'}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
{validatorTypes.map(({ field, label, validators: vNames, type, placeholder }) => {
const err = errors[field];
const isCross = field === 'confirm' && passwordMismatch;
const showErr = isTouched(field) && (err || isCross);
return (
<div key={field}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<label style={{ fontSize: 11, color: '#94a3b8' }}>{label}</label>
<div style={{ display: 'flex', gap: 4 }}>
{vNames.map(v => (
<span key={v} style={{ fontSize: 9, padding: '1px 5px', borderRadius: 10, background: '#1e293b', color: '#7dd3fc', border: '1px solid #334155' }}>
{v}
</span>
))}
</div>
</div>
<input
type={type}
placeholder={placeholder}
value={fields[field]}
onChange={e => change(field, e.target.value)}
onBlur={() => touch(field)}
style={inputStyle(field)}
/>
{showErr && (
<div style={{ color: '#f87171', fontSize: 11, marginTop: 3 }}>
⚠️ {isCross ? 'Пароли не совпадают (cross-field)' : getErrorMsg(err)}
</div>
)}
</div>
);
})}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<input
type="checkbox"
checked={fields.terms}
onChange={e => { change('terms', e.target.checked); touch('terms'); }}
style={{ accentColor: '#dd0031' }}
/>
<label style={{ fontSize: 13, color: '#94a3b8' }}>Согласен с условиями (requiredTrue)</label>
{isTouched('terms') && errors.terms && <span style={{ color: '#f87171', fontSize: 11 }}>⚠️ Необходимо</span>}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleSubmit}
style={{ background: '#dd0031', color: 'white', border: 'none', padding: '10px 20px', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}
>
Submit (markAllAsTouched)
</button>
<button
onClick={() => { setFields({ name: '', email: '', phone: '', age: '', password: '', confirm: '', terms: false }); setTouched({}); setShowAll(false); }}
style={{ background: 'transparent', color: '#64748b', border: '1px solid #334155', padding: '10px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}
>
Сброс
</button>
{showAll && isValid && (
<span style={{ padding: '10px', color: '#22c55e', fontSize: 13 }}>🎉 Форма валидна!</span>
)}
</div>
</div>
);
}