Валидация пользовательского ввода как продукт: ошибки, подсказки и согласованные ответы
Построим стратегию ошибок валидации: единый формат, человеко-читаемые сообщения и воспроизводимые коды ошибок. Проговорим, как сделать так, чтобы фронтенд и клиенту было понятно, что исправлять.
Содержание
Валидация пользовательского ввода как продукт: ошибки, подсказки и согласованные ответы
Валидация пользовательского ввода — это не “техническая мелочь”, а часть продуктовой коммуникации. Пользователь не должен угадывать, что именно сломалось и как исправить. Системе нужен предсказуемый протокол ошибок, а фронтенду — единые правила отображения сообщений. Иначе в интерфейсе появляется хаос: то подсветка без текста, то текст без поля, то коды без смысла, то разные форматы ответов от разных эндпоинтов.
В этой статье разберём, как построить стратегию валидации так, чтобы:
- формат ошибок был единым во всех сценариях;
- сообщения были человеко-читаемыми, а не “сырой” технической строкой;
- ошибки имели воспроизводимые коды (стабильные ключи для логики фронтенда и для поддержки);
- фронтенду и клиенту было понятно, что именно исправлять и как быстро дойти до “успеха”.
Подход универсален: он подходит и для монолитов, и для микросервисов, и для публичных API. Примеры будем показывать на JSON-подобных структурах, которые легко перенести в любую платформу.
Что ломается, когда валидация “не как продукт”
Типовые проблемы, которые встречаются в реальных продуктах:
Несогласованные форматы ошибок
Один эндпоинт возвращает { error: "Invalid email" }, другой — { message: "...", details: [...] }, третий — вообще HTML. Фронтенду приходится писать “зоопарк” обработчиков.
Ошибки без контекста
Пользователь видит сообщение “Некорректное значение”, но не понимает:
- для какого поля;
- что именно неверно (формат, длина, уникальность, отсутствующее значение);
- как исправить.
Сообщения зависят от языка и инфраструктуры
Например, сообщение пришло из БД: “value too long for type character varying(50)”. Это не помогает человеку, но ещё и раскрывает детали реализации.
Коды ошибок нестабильны
В логике интерфейса “код ошибки” берётся как текст (“Invalid email”), или он генерируется на лету. В итоге любые изменения на бэкенде ломают фронтенд.
Проблемы с массовой валидацией
В сложных формах валидация часто возвращает массив ошибок, но формат этого массива не определён: то это массив строк, то массив объектов с разными ключами, то иногда — одна ошибка, иногда — несколько. Интерфейс снова “плывёт”.
Итог: даже при корректной бизнес-логике пользователи уходят, потому что они не понимают, как исправить форму.
Базовый принцип: ошибка — это контракт
Сильный подход к валидации — рассматривать её как контракт между:
- сервером (который определяет правила и источники истины);
- клиентом/фронтендом (который показывает ошибки);
- пользователем (который читает сообщение и действует).
Контракт должен быть:
- Единым по форме (одинаковая структура ответа для всех ошибок валидации);
- Машиночитаемым и человеко-читаемым (и для UI, и для поддержки);
- Стабильным по кодам (ключи не меняются в течение жизненного цикла версии API);
- Предсказуемым по статусам HTTP (где это уместно);
- Достаточным по контексту (поле, правило, подсказка, где применимо — “как исправить”).
Проектирование формата ответа об ошибках
Выбор HTTP-статуса
Практически всегда для проблем валидации уместны 4xx-сценарии. Ключевые варианты:
- 400 Bad Request — запрос синтаксически некорректен/не проходит валидацию формы. Часто это основной вариант.
- 422 Unprocessable Entity — более точный смысл: запрос корректен синтаксически, но семантически невалиден (например, поле не соответствует правилам).
- 409 Conflict — бизнес-условия конфликтуют (например, email уже занят), хотя иногда это ошибку валидации тоже относят в 422.
Если вы не хотите дробить — берите 422 для валидации “полей” и 409 для уникальности/конфликтов, но самое важное — быть последовательными.
Унифицированная структура JSON
Цель — чтобы фронтенд мог один раз написать обработчик.
Один из удачных шаблонов:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации данных",
"traceId": "b3f3a2c7-0c2f-4c2c-9e2b-1a7b7d7a9d11",
"details": [
{
"field": "email",
"code": "EMAIL_INVALID_FORMAT",
"message": "Введите email в формате name@domain.com",
"help": "Проверьте, что есть символ '@' и домен без пробелов",
"value": "not-an-email"
},
{
"field": "password",
"code": "PASSWORD_TOO_SHORT",
"message": "Пароль должен содержать минимум 8 символов",
"help": "Используйте 8+ символов и попробуйте добавить цифры",
"min": 8
}
]
}
}
Обратите внимание на назначение полей:
error.code— общий класс ошибки (стабильный).error.message— нейтральная “шапка” (для логов/общего уведомления).error.traceId— для корреляции в логах.details— массив конкретных проблем.- у каждого элемента есть:
field— путь к полю в форме (строго определённая семантика);code— стабильный ключ правила/типа;message— человеко-читаемая строка;help— дополнительная подсказка (не всегда нужна);- опциональные параметры (
min,value) — полезно для UI и отладки.
Нужны ли value и прочие поля?
Обычно значение (value) полезно для отладки и для локальной логики (например, подсветить “слишком длинная строка — X символов”). Но есть риск утечек чувствительных данных.
Практика:
- для полей вроде
password,token,ssn— не возвращать значение; - возвращать длину или маскированный вид, если нужно:
"value": "***"или"length": 12; - для остальных — осторожно, оцените риски (логирование, аналитика, сохранение ошибок на клиенте).
Дизайн “воспроизводимых” кодов ошибок
Коды ошибок — это не “внутренние названия” хаотично из разных модулей. Это каталог причин, которые можно повторять между версиями.
Хороший код должен быть:
- стабильным: не меняется от рефакторинга текста;
- семантически ясным: отражает тип проблемы;
- достаточно гранулярным, чтобы фронтенд понимал, какую подсказку показать.
Примеры кодов:
EMAIL_INVALID_FORMATEMAIL_REQUIREDPASSWORD_TOO_SHORTPASSWORD_WEAKUSERNAME_ALREADY_TAKENDATE_IN_PAST
Допустимая и полезная структура:
- верхний уровень = домен или категория (
AUTH,PROFILE); - нижний уровень = конкретное правило (
EMAIL_INVALID_FORMAT). Но главное — единообразие.
Разделяйте: “код поля” и “код правила”
Иногда путают два слоя:
field: где ошибка (например,address.zip);code: почему ошибка (например,ZIP_INVALID_PATTERN).
Они нужны одновременно.
Человекочитаемость: сообщение должно отвечать на “что исправить”
Пользователь задаёт три вопроса:
- Что именно не так?
- Что нужно сделать?
- Как быстро проверить, что стало лучше?
Поэтому в message должны быть ответы на пункты 1–2. В help — пункт 3 или дополнительное объяснение.
Плохие сообщения
- “Invalid input” — непонятно.
- “Field failed validation” — непонятно.
- “value too long” — технически, без действий.
Хорошие сообщения
- “Введите email в формате name@domain.com”
- “Пароль должен содержать минимум 8 символов”
- “Индекс должен содержать 5 цифр”
Локализация и составные сообщения
Если вы планируете мультиязычность, лучше:
- хранить
messageна сервере только для одного языка (или для языка пользователя из запроса); - или возвращать только код и
params, а текст собирать на клиенте.
Оба подхода имеют минусы и плюсы. На практике часто делают так:
codeиparams— сервером;message— либо сервером (в языке пользователя), либо клиентом (по словарю).
Ключевой принцип: фронтенд не должен парсить текст. Он должен опираться на code.
Как связать ошибки с UI: “поле”, “путь” и повторяемость
Семантика field
Валидация часто применяется к форме, где поля вложены. Например:
{
"user": {
"email": "a@b.com"
},
"profile": {
"firstName": "Ivan"
}
}
Если вернуть field: "user.email", фронтенд должен быть способен сопоставить это с компонентом. Для этого важно договориться о формате:
- поддерживать точечную нотацию (
user.email); - или JSON pointer (
/user/email); - или массив сегментов (
["user", "email"]).
Чаще всего достаточно точечной нотации, но нужно обеспечить:
- единообразие во всей системе;
- наличие маппинга на фронтенде, если форма использует другие имена.
Индексы в массивах
Для формы с коллекциями:
items[0].quantityitems[1].quantity
Сервер должен возвращать те же индексы, что и запрос. Если на сервере происходит нормализация порядка или фильтрация — индексы станут несоответствием. Поэтому:
- либо сохраняйте исходный порядок,
- либо возвращайте “уникальный идентификатор элемента” (например,
id) вместо индекса.
Единый формат для множественных ошибок и группировок
В сложных формах одна отправка может содержать 10–50 проблем. Нужно:
- вернуть все ошибки, а не “первую попавшуюся”;
- показать пользователю максимум полезного, но без перегруза интерфейса.
Серверные принципы
- валидируйте последовательно: синтаксис → базовые ограничения → бизнес-правила;
- на уровне бизнес-правил допускайте уникальность/конфликты как отдельные
code; - собирайте ошибки в массив и отдавайте одним ответом.
Клиентская логика
Фронтенд обычно делает:
- подсветку полей по
field; - общий баннер “Проверьте поля ниже” по
error.messageили константе; - блок с ошибками, если поле не найдено (fallback).
Важный момент: коды ошибок должны быть воспроизводимыми — тогда можно гарантировать, что один обработчик “понимает” все случаи.
Согласованная логика: фронтенд и сервер должны говорить на одном языке
Чтобы клиенту было понятно, что исправлять, серверу нужно сообщить:
- какой “тип” проблемы (
code); - для какого поля (
field); - что именно сделать (
message/help); - какие параметры применять (
min,max,pattern,required).
Пример: возвращаем pattern-подсказку, но не раскрываем внутренности
Допустим, вы хотите объяснить формат телефонного номера. Не обязательно возвращать regex. Лучше вернуть правило:
{
"field": "phone",
"code": "PHONE_INVALID_FORMAT",
"message": "Введите номер в формате +7XXXXXXXXXX",
"help": "Например: +71234567890"
}
Если UX требует более строгой настройки — можно возвращать example или formatHint.
Типичные ошибки реализации
1) Смешивать “валидацию” и “ошибки сервера”
Ошибки 500 и ошибки валидации — разные классы. Валидация должна попадать в 4xx (400/422), 500 — только для исключений инфраструктуры.
2) Возвращать только HTTP-статус без тела
Даже если “в клиенте всё равно ловим статус”, вы потеряете подсказки. Тело ошибки — это контракт UX.
3) Генерировать коды на основе текста
Если code содержит “Invalid email” — любой перефраз сломает обработку. Код должен быть инвариантен к языку.
4) Ставить один и тот же field для разных проблем
Например, у вас:
field: "email"иcode: EMAIL_REQUIRED- или
field: "email"иcode: EMAIL_INVALID_FORMAT
Это нормально — но если вы начнёте возвращать одинаковые code для разных причин, фронтенд потеряет точность.
5) Возвращать ошибки без field в полях формы
Для общих ошибок (например, “нельзя продолжить сейчас”) можно вернуть field: null или вообще без field, но тогда UI должен заранее уметь показать это как “глобальную ошибку”. Не оставляйте ситуацию на угадывание.
Пример API: запрос, правила и ответ с валидацией
Рассмотрим эндпоинт регистрации. Допустим, приходят данные:
{
"email": "not-an-email",
"password": "123",
"agree": true
}
Валидационные правила:
emailобязателен и должен соответствовать формату;passwordминимум 8 символов;agreeобязателен и должен бытьtrue.
Сервер возвращает:
- HTTP
422 Unprocessable Entity - тело ошибки в едином формате.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Ошибка валидации данных",
"details": [
{
"field": "email",
"code": "EMAIL_INVALID_FORMAT",
"message": "Введите email в формате name@domain.com",
"help": "Проверьте, что в адресе есть символ '@' и нет пробелов",
"value": "not-an-email"
},
{
"field": "password",
"code": "PASSWORD_TOO_SHORT",
"message": "Пароль должен содержать минимум 8 символов",
"help": "Используйте комбинацию букв и цифр",
"min": 8
}
]
}
}
Обратите внимание: ошибки по agree нет — значит сервер проверил и принял поле.
Реализация: сервер собирает ошибки в единый формат
Ниже — псевдореализация на “языке мысли”: вы создаёте тип ошибки валидации, добавляете элементы в массив, а затем сериализуете.
Модель ошибок
type ValidationErrorDetail = {
field?: string; // например: "email" или "user.email"
code: string; // например: "EMAIL_INVALID_FORMAT"
message: string; // человеко-читаемое сообщение
help?: string; // дополнительная подсказка
min?: number;
max?: number;
valueMasked?: string; // вместо value для чувствительных полей
// любые другие параметры, нужные UI/логике
};
type ValidationErrorResponse = {
error: {
code: "VALIDATION_ERROR" | string;
message: string;
traceId?: string;
details: ValidationErrorDetail[];
};
};
Генерация ответа валидации
function validationErrorResponse(details: ValidationErrorDetail[], traceId?: string): ValidationErrorResponse {
return {
error: {
code: "VALIDATION_ERROR",
message: "Ошибка валидации данных",
traceId,
details
}
};
}
Дальше на практике вы делаете маппинг от “внутренних” причин к code, field и message.
Реализация: фронтенд использует коды, а не текст
Предположим, фронтенд получает ответ с details и превращает его в отображение.
Идея: создать единый редьюсер ошибок или функцию маппинга.
Пример логики маппинга
type ServerValidationDetail = {
field?: string;
code: string;
message: string;
help?: string;
min?: number;
};
function mapValidationErrors(details: ServerValidationDetail[]) {
// Ключ — поле в форме
const byField: Record<string, { code: string; message: string; help?: string }> = {};
const global: { code?: string; message: string; help?: string }[] = [];
for (const d of details) {
if (d.field) {
byField[d.field] = { code: d.code, message: d.message, help: d.help };
} else {
global.push({ code: d.code, message: d.message, help: d.help });
}
}
return { byField, global };
}
Дальше UI:
- подсвечивает
byField[field]; - если
globalне пуст — показывает общий баннер/тоаст
Комментарии
Пока нет комментариев