Содержание урока · 10 разделов
- validationerrors go: два типа ошибок из Struct
- validator field tag: методы FieldError
- Методы FieldError на одном примере
- Первый провалившийся тег побеждает
- InvalidValidationError: когда сломался код
- От простыни к конверту 400
- Что вытащить из FieldError для ответа
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Вот эту строку вы видели в уроке про первый запуск: Key: 'SignupForm.Password' Error:Field validation for 'Password' failed on the 'min' tag. Английская, подробная и совершенно непригодная для пользователя — но присмотритесь, сколько в ней информации: имя структуры, имя поля, провалившийся тег. Всё это валидатор складывает в структуру ошибки, и сегодня мы научимся доставать части по отдельности: тип ValidationErrors, каждый его элемент FieldError, методы Field, Tag, Param и компания. К концу урока простыня Key: '...' превратится в сырьё для понятных ответов 400, а полная система сообщений на русском станет темой шестого урока. Умение читать структуру ошибки — это навык, который отделяет валидацию «как в туториале» от валидации, за которую не стыдно на ревью.
validationerrors go: два типа ошибок из Struct#
err, который возвращает validate.Struct, — один из двух типов, и различать их нужно на уровне кода. Первый — validator.ValidationErrors: срез FieldError, по записи на каждое провалившееся поле; это нормальный рабочий сценарий — данные не прошли правила, отвечаем 400 по полям. Второй — *validator.InvalidValidationError: валидатору передали не структуру — nil, число, срез; это ошибка программиста, и её место в логах или панике на старте, а не в ответе клиенту. Оба типа — конкретные значения, реализующие error, поэтому достают их через errors.As с указателем на нужный тип: это безопаснее прямого приведения типов, потому что err может быть завёрнут другим слоем. Порядок веток имеет значение по соображениям читаемости: сначала узкая InvalidValidationError, потом широкая ValidationErrors — формально они не пересекаются, но такой порядок читается как чек-лист: баг кода, отказ данных, всё остальное. Стандартный паттерн — в блоке ниже.
Две ветки errors.As — канонический разбор
GOerr := validate.Struct(&form)
if err != nil {
var invalid *validator.InvalidValidationError
if errors.As(err, &invalid) {
// не структура: баг кода, не данные
panic(fmt.Sprintf("validate.Struct: %v", invalid))
}
var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
for _, e := range verrs {
fmt.Println(e.Field(), e.Tag(), e.Param())
}
// Password min 8
return
}
// всё остальное — неожиданно, общий обработчик
}Две ветки errors.As по типам: InvalidValidationError — не-структура на входе, ValidationErrors — провал правил. В цикле по verrs каждая запись отдаёт имя поля, тег и параметр тремя методами; для Password с тегом min=8 цикл напечатает Password min 8. Всё остальное в err считайте неожиданным и уходите в общий обработчик ошибок.
validator field tag: методы FieldError#
FieldError — маленькая, но говорящая структура. Field() возвращает имя поля структуры: Password; для элементов среза после dive — имя с индексом, вроде Tags[2]. Tag() — тег, на котором проверка споткнулась: required, min, email. Param() — параметр этого тега строкой: «8» для min=8, «admin user» для oneof, пустая строка для required — у него параметров нет. Value() — само значение поля, которое не прошло: удобно для логов, но не для ответа клиенту. Namespace() — полный путь с точками: SignupForm.Address.City; он оживёт в уроке про вложенные структуры, а пока запомните сам факт. ActualTag() возвращает реальный тег без алиасов — на простых тегах совпадает с Tag(), разница появляется с RegisterAlias, когда одному правилу придумывают своё имя. Kind() и Type() — reflect-категория и тип поля, если хочется ветвиться по ним: строка это или число, int или time.Time; так, каталог сообщений может для числовых полей говорить «минимум N», а для строковых — «минимум N символов», не заводя отдельные теги. И метод Error(), который собирает ту самую простыню Key: '...' — теперь понятно, из каких частей. Для сообщений на русском нужны первые три — Field, Tag и Param — плюс словарь; остальное пригодится в специфических сценариях. И ещё разница, которую полезно понимать заранее: Field() и Namespace() отдают имена по настоящим полям структуры, без переименований по json-меткам — валидатор про сериализацию не знает, у него свой взгляд на имена; будем помнить при маппинге.
Методы FieldError на одном примере#
| Метод | Что возвращает | Пример для Password при min=8 |
|---|---|---|
| Field() | имя поля структуры | Password |
| Tag() | тег, на котором провалилась проверка | min |
| Param() | параметр тега строкой | 8 |
| Value() | значение поля, которое не прошло | abc |
| Namespace() | полный путь через точки | SignupForm.Password |
| ActualTag() | тег без алиасов | min |
| Error() | собранная строка Key: '...' Error:... | Key: 'SignupForm.Password' Error:... |
Проверка: Param у oneof
Role string с тегом oneof=admin user получил значение root. Что вернёт e.Param()?
Первый провалившийся тег побеждает#
Важное свойство исполнения: поле проверяется по правилам слева направо, и первый провалившийся тег завершает проверку этого поля. Password string с тегом required,min=8 и значением «» вернёт одну запись с тегом required — до min дело не дойдёт, пустая строка уже провалила первый тег. Из этого два следствия. Первое: не рассчитывайте увидеть в ValidationErrors все причины по одному полю — полный список причин по полю валидатор не соберёт; если бизнесу нужен пошаговый диалог, проверяйте повторно после каждого исправления или делите форму на шаги, где каждая валидация отвечает только про текущий шаг. Второе: порядок правил в теге управляет тем, какое сообщение получит пользователь: required первым даёт «укажите поле», а min первым — «слишком коротко» даже для пустой строки. В курсе мы ставим required первым — это договорённость между тегом и текстом подсказки, которая делает сообщения предсказуемыми. На практике из этого свойства вырастает целая дисциплина UX: длинная форма с десятью ошибками не должна возвращать все десять, если девять из них — следствия одной пустой строки; пошаговые формы с валидацией текущего шага дают клиенту короткий список и держат сообщения осмысленными. А если продукт требует показать все проблемы разом, собирайте их отдельным проходом по полям — правила слева направо это механика валидатора, а не закон интерфейса. При этом сами поля независимы: три сломанных поля дадут три записи, и клиент получит весь список разом — это и есть преимущество собирающей валидации перед стоп-на-первой-ошибке. И последнее свойство, которое стоит прочувствовать: порядок записей в срезе следует порядку полей структуры — Password раньше Age. Не полагайтесь на это в логике ответа, но для чтения логов знание полезное: запись с незнакомым полем значит, что смотрят не ту версию структуры.
Сколько записей и какие теги
Прочитайте код и выберите, что он выведет:
type Profile struct {
Password string `validate:"required,min=8"`
Age int `validate:"gte=0"`
}
p := Profile{Password: "", Age: -1}
err := validate.Struct(&p)
// сколько записей в ValidationErrors и какие теги?Мини-каталог: от тега к тексту
GOfunc fieldMessage(e validator.FieldError) string {
switch e.Tag() {
case "required":
return "обязательное поле"
case "min":
return fmt.Sprintf("минимум %s символов", e.Param())
case "max":
return fmt.Sprintf("максимум %s символов", e.Param())
case "email":
return "укажите адрес почты"
default:
return e.Field() + ": проверка " + e.ActualTag() + " не прошла"
}
}Мини-каталог: текст подбирается по Tag(), параметр тега подставляется в сообщение — минимум 8 символов. Ветка default с ActualTag() — страховка: незнакомый тег не вернёт пустоту, а даст честную техническую строку, по которой вы узнаете о пропущенном кейсе. Полная система с русскими текстами и уточнениями по полям — тема шестого урока.
Field() возвращает имя поля СТРУКТУРЫ, а фронтенд знает JSON-имя. Поле Password string с json:"password" в ошибке — «Password», а клиент подсвечивает поля по «password». На camelCase-проектах это совпадает с точностью до регистра; на snake_case-фронтах не совпадает вовсе, и подсветка молча не срабатывает. Решения: маппинг структурных имён в JSON через reflect, отправка пути Namespace(), который фронт умеет разбирать, или конвенция одинаковых имён. Классический симптом непочиненного маппинга: 400 приходит, текст правильный, а инпут не подсвечен — а причина в первой букве.
InvalidValidationError: когда сломался код#
*InvalidValidationError заслуживает отдельного блока, потому что его появление почти всегда означает баг в вашем коде, а не в данных. Валидатор возвращает его, когда в Struct передали nil, не-указатель, указатель на не-структуру. Типичные сценарии из жизни: функция принимает any и доходит до validate.Struct(data) с числом внутри; тест передаёт nil «а вдруг сработает»; рефакторинг сменил сигнатуру, и вместо структуры поехала ошибка предыдущего шага. Обращение однообразное: это не пользовательская история — логируйте с контекстом или паникуйте на старте, чтобы баг всплыл при первом же прогоне, а не жил годами в тёмной ветке. Практика с паникой на старте выглядит жёстко, но у неё есть обоснование: InvalidValidationError возможен только там, где потерян тип данных — после рефакторинга или в generic-обёртке; такие места всплывают на первом интеграционном тесте, и лучше упасть в тестовом окружении с внятной причиной, чем молча пропускать проверку в проде. Если паника в вашем сервисе недопустима, логируйте на уровне error и возвращайте 500 — главное, не превращайте её в 400: пользователь не виноват в том, что код передал валидатору не структуру. Отсюда же правило для обёрток: функция-помощник validateOf(data any) error обязана различать оба типа и документировать, что InvalidValidationError наружу не отдаётся. В тестах правил — отдельный урок про тесты — InvalidValidationError проверяют явно: подают nil и ожидают именно этот тип, а не ValidationErrors; это фиксирует контракт проверки в коде.
Проверка: что вернёт Error()
Что вернёт err.Error() у ValidationErrors с тремя записями?
Простыня Key: 'SignupForm.Password' Error:... не годится клиенту, но хороша в журнале: одна строка с полным списком провалов делает запись о 400 самодостаточной для расследования. Компромисс, который работает: клиенту — Details с понятными сообщениями, в лог — err.Error() целиком. Главное — не наоборот.
От простыни к конверту 400#
Соберём практику урока в принципы ответа клиенту. Не отдавайте err.Error() как есть: английская простыня, утечка внутренних имён полей и структуры — и всё это вместо двух слов по-русски. Не отдавайте и Value() провалившихся значений: в ошибке уедет то, что ввёл пользователь, включая пароли и паспорта; значение — для логов, и не для всех полей. Отвечайте списком Details: по записи на каждое сломанное поле, собранной из Field, Tag, Param и словаря сообщений, — так фронтенд подсвечивает инпуты один к одному. Этот конверт — тема двух уроков: шестой соберёт каталог русских текстов, двадцать четвёртый оформит единый формат ошибок API с кодами и полем. А если вы работаете в Gin, тот же паттерн уже разобран в двенадцатом уроке — с тегами binding и ShouldBindJSON; механика FieldError при этом идентична. Практическая проверка принципов простая: откройте любой свой ответ 400 и посчитайте, сколько в нём утечёк из прошлого — технических имён, английских тегов, значений полей. Каждая утечка — это ровно то, что мы сегодня разложили по методам.
Что вытащить из FieldError для ответа#
- Field() — имя поля для маппинга в JSON-имя и подсветки инпута
- Tag() — ключ каталога сообщений: один текст на все поля с этим тегом
- Param() — параметр тега: граница диапазона или перечень oneof подставляются в текст
- Value() — провалившееся значение: в лог, но не в ответ клиенту
- Namespace() — полный путь через точки: понадобится во вложенных структурах
Соберите разбор ошибок
Восстановите тип ошибки, errors.As и два метода FieldError — четыре пропуска.
Соберите путь ошибки в ответ 400
От err до Details в ответе — расставьте шаги по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: анатомия ошибки
Программу урока соберите локально — песочница сайта валидатор не исполняет. Самая полезная тренировка: напечатать Field, Tag, Param, Value и Namespace одной строкой на живом провале. Когда увидите их на настоящей ошибке, а не в таблице, каталог сообщений соберётся почти сам.
Шестой урок — русские сообщения: двухуровневый каталог, уточнения по полям и согласование по числам. Двенадцатый — тот же механизм в Gin с тегами binding и ShouldBindJSON. Двадцать четвёртый — единый формат ошибок API с кодами и Details. А соседний разбор из курса Gin — валидация в Gin — покажет, как тот же FieldError живёт внутри фреймворка и собирает Envelope-конверт с кодом VALIDATION_FAILED: синтаксис там отличается только словарём тегов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Struct возвращает один из двух типов: ValidationErrors — отказ правил, InvalidValidationError — не-структура на входе.
- Оба типа достаются errors.As с указателем на тип; сначала узкая ветка про баг кода, потом рабочая про данные.
- Field() — имя поля структуры, Tag() — споткнувшийся тег, Param() — его параметр строкой; этого хватает для каталога сообщений.
- Первый провалившийся тег завершает проверку поля: Password с «» даст required, а не min.
- Поля независимы: три сломанных поля — три записи в ValidationErrors, клиент получает список разом.
- Field() — не JSON-имя: для подсветки полей на фронте нужен маппинг или Namespace().
- err.Error() — для лога; клиенту едут Details из каталога сообщений, Value() наружу не отдаётся.
Домашнее задание
Возьмите форму из прошлых уроков с тремя полями и сломайте её двумя способами: пустыми значениями и значениями вне диапазона. Напечатайте для каждого случая все пять методов FieldError — Field, Tag, Param, Value, Namespace — и убедитесь, что у Password с пустой строкой тег required, а не min. Потом напишите fieldMessage с ветками на required, min и max и соберите ответ, где для каждой записи есть русское сообщение.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему в ошибке тег required, хотя в поле стоит и min?
Поле проверяется по правилам слева направо до первого провала. Пустая строка провалила required, и до min дело не дошло. Исправьте required — следующая проверка ответит уже про min. Это же свойство используют тесты: подавая заведомо пустые данные, ожидайте required, а не диапазон, — так контракт правил фиксируется в коде.
Как отличить ошибку данных от бага кода?
По типу: validator.ValidationErrors — нормальный отказ правил, данные не прошли; *validator.InvalidValidationError — валидатору передали не структуру, это баг. Разводите их двумя ветками errors.As и обрабатывайте по-разному.
Можно ли отправлять клиенту Value() провалившегося поля?
Не стоит: в значении может быть пароль, телефон или что-то конфиденциальное. Value() — инструмент для логов, причём не для всех полей; клиенту едут Field, Tag и Param, из которых собираются понятные сообщения без утечек.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon