22. Валидация форм
✅ Валидация форм в Angular
Заголовок раздела «✅ Валидация форм в Angular»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
Заголовок раздела «📋 Template-driven валидаторы в HTML»В 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>🎨 Паттерны отображения ошибок
Заголовок раздела «🎨 Паттерны отображения ошибок»Паттерн 1: Простые сообщения
Заголовок раздела «Паттерн 1: Простые сообщения»<div *ngIf="email.invalid && email.touched" class="error-block"> <p *ngIf="email.errors?.['required']">Email обязателен</p> <p *ngIf="email.errors?.['email']">Неверный формат email</p></div>Паттерн 2: Методы в компоненте
Заголовок раздела «Паттерн 2: Методы в компоненте»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>Паттерн 3: Pipe для ошибок (реусабельный)
Заголовок раздела «Паттерн 3: Pipe для ошибок (реусабельный)»@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>🔑 AbstractControl — базовый класс
Заголовок раздела «🔑 AbstractControl — базовый класс»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 если был blurctrl.untouched // обратное touched
// Ошибкиctrl.errors // { required: true } | nullctrl.hasError('required') // проверка конкретной ошибкиctrl.getError('minlength') // получить данные ошибки
// Методыctrl.markAsTouched()ctrl.markAsDirty()ctrl.setErrors({ custom: 'message' })ctrl.clearValidators()ctrl.addValidators([Validators.required])ctrl.updateValueAndValidity()❌ Объект errors
Заголовок раздела «❌ Объект errors»Ошибки хранятся в объекте — ключ это имя валидатора:
// Для Validators.requirederrors = { required: true }
// Для Validators.minLength(3) с value='ab'errors = { minlength: { requiredLength: 3, actualLength: 2 }}
// Для Validators.max(100) с value=150errors = { max: { max: 100, actual: 150 }}
// Для Validators.patternerrors = { 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>🎯 markAllAsTouched при submit
Заголовок раздела «🎯 markAllAsTouched при submit»Чтобы показать ошибки при нажатии 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> );}