---
title: "Русские сообщения об ошибках"
description: "Сообщения валидации go на русском: двухуровневый каталог по Tag и Field, согласование числительных, RegisterTagNameFunc, перевод через universalTranslator."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/06-russkie-soobshcheniya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Русские сообщения об ошибках

*Курс «Валидация в Go: go-playground/validator», урок 6 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/validator/lessons/06-russkie-soobshcheniya/*

Сообщения валидации go должен понимать человек, а не разработчик: «Password: проверка min не прошла» технически честно и совершенно бесполезно для пользователя. В этом уроке собираем систему русских текстов: двухуровневый каталог по тегам и полям, согласование числительных, json-имена через RegisterTagNameFunc и выбор между своим словарём и universalTranslator.

После пятого урока у вас на руках всё сырьё: Field() даёт имя поля, Tag() — споткнувшийся тег, Param() — его параметр. Осталось превратить сырьё в текст, который пользователь прочтёт без гугла. Разница между «тупым» и человечным ответом видна на одном поле: «Password: проверка min не прошла» против «Пароль — от 8 символов». Информация та же, цена для поддержки разная: первый вариант генерирует тикеты «что мне делать?», второй — сам себя объясняет. В этом уроке мы не будем трогать валидатор — все механики уже разобраны; речь о слое над ним, где живут русские тексты. Задача слоя простая: по каждой записи FieldError выдать строку, а правила этой выдачи — один раз и в одном месте, потому что сообщения, как и валидация, расползаются по кодовой базе мгновенно. Кладите слой рядом с валидатором, но отдельно от хендлеров: пакет messages с парой словарей и одной функцией собирается за полчаса и переезжает из проекта в проект почти без изменений — приёмы урока не зависят ни от фреймворка, ни от набора тегов, поэтому написанное здесь однажды работает и в Gin, и в Echo, и в фоновых задачах без HTTP вовсе.

### Сообщения валидации go: каталог вместо веток if

Главная ошибка стартовой версии любого API — писать тексты прямо в хендлере: switch по тегам, рядом с разбором ValidationErrors. На одном эндпоинте это выглядит невинно, а на четвёртом выясняется, что «обязательное поле» в регистрации, «укажите поле» в профиле и «поле не заполнено» в подписке — три разных текста для одного и того же required. Сообщения валидации go собираются в каталог — обычный package рядом с валидатором, где ключом служит Tag(), а значением — готовый русский текст. Хендлер умеет только одно: перебрать записи, позвать функцию-сообщалку и сложить строки в Details ответа. Двухуровневая структура каталога вытекает из самой FieldError: первый уровень — тексты по тегам, общие для всех полей проекта; второй — уточнения по конкретным полям, потому что «обязательное поле» одинаково честно и для пароля, и для города, а вот «минимум 8 символов» для пароля и «от 3 до 32 символов» для имени — разные разговоры. Порядок проверки уровней фиксированный: сначала уточнение по полю, потом общий текст по тегу — иначе уточнения никогда не сработают. И обязательная страховка на оба уровня: ветка default, которая отдаёт техническую строку с ActualTag(), а не пустоту. Незнакомый тег в каталоге — не повод молчать, а повод узнать о дыре в каталоге из ответа API или лога.

### Двухуровневый каталог: уточнение сильнее общего

```go
var tagTexts = map[string]string{
        "required": "обязательное поле",
        "email":    "укажите адрес почты",
        "oneof":    "допустимые значения: %s",
}

var fieldTexts = map[string]string{
        "Phone":  "телефон в формате +7XXXXXXXXXX",
        "Promo":  "промокод — от 4 до 16 символов",
}

func humanMessage(e validator.FieldError) string {
        if t, ok := fieldTexts[e.Field()]; ok {
                return t // уточнение по полю сильнее общего текста
        }
        t, ok := tagTexts[e.Tag()]
        if !ok {
                return e.Field() + ": проверка " + e.ActualTag() + " не прошла"
        }
        return fmt.Sprintf(t, e.Param())
}
```

Словарь tagTexts обслуживает все поля проекта, fieldTexts — отдельные поля, которые говорят по-своему. Порядок важен: уточнение по Field() проверяется первым, иначе общий текст по Tag() перехватит запрос раньше. Для oneof параметр с перечнем подставляется в шаблон через Sprintf, для required лишний аргумент безвреден. Ветка default с ActualTag() — та же страховка, что и в мини-каталоге прошлого урока.

### Шаблоны сообщений: от тега к тексту

| Тег | Шаблон текста | Что подставляется |
| --- | --- | --- |
| required | обязательное поле | ничего |
| min / max | минимум N символов | Param() — граница |
| email | укажите адрес почты | ничего |
| oneof | допустимые значения: %s | Param() — перечень |
| gte / lte | значение от N до M | Param() — граница |
| default | проверка X не прошла | ActualTag() — имя тега |

### Уточнение по полю: когда общего текста мало

Общие тексты по тегам — это основа, но в каждом проекте есть поля, которые заслуживают персонального разговора. Телефон: общий текст min=10 скажет «минимум 10 символов», а пользователь не понимает, в каком формате хочет номер сервис; уточнение fieldTexts["Phone"] говорит «телефон в формате +7XXXXXXXXXX» — и поддержка получает на один тикет меньше. Промокод: min=4 одинаково справедлив для кода и для фамилии, но «промокод — от 4 до 16 символов» снимает вопрос «а что сюда вообще писать». Дата рождения: требование формата лучше сформулировать один раз на поле, чем надеяться, что общий текст datetime догадается. Правило отбора простое: если формулировка общего текста не отвечает на вопрос «что мне ввести», полю положено уточнение. Второе правило — дисциплина объёма: уточнений должно быть на порядок меньше, чем полей, иначе второй уровень разрастается в параллельный каталог, который некому поддерживать. И третье — уточнение по полю выигрывает у уточнения по типу: если хочется для числовых полей говорить «минимум N», а для строковых «минимум N символов», посмотрите на Kind() и Type() из пятого урока — каталог может ветвиться и по ним, без второго словаря.

### Проверка: чей текст сильнее

**Вопрос:** В каталоге есть общий текст для required и уточнение для поля Phone. Поле Phone провалило required. Что вернёт humanMessage?

- Общий текст по тегу: «обязательное поле»
- ✅ Уточнение по полю: «телефон в формате +7XXXXXXXXXX»
- Техническую строку из ветки default
- Пустую строку: required не покрыт уточнениями

Уточнение по полю проверяется раньше общего текста — это зафиксировано порядком в humanMessage. Переставьте проверки местами, и fieldTexts превратится в мёртвый код: до словаря полей дело никогда не дойдёт. Такие ловушки ловятся одним тестом: пустая форма, каждое поле каталога должно ответить своей веткой.

### Согласование числительных: 1 символ, 2 символа, 5 символов

Русский язык добавляет валидатору проблему, которой нет у английских туториалов: числительные склоняются. Шаблон «минимум N символов» на N=1 выдаёт «минимум 1 символов» — и это ровно тот уровень небрежности, который пользователи замечают мгновенно. Форм три: один символ, два символа, пять символов, и выбор зависит от двух последних цифр. Функция на десяток строк закрывает вопрос навсегда — и переиспользуется во всех шаблонах с числом: символы, элементы, байты, минуты. Есть и честный обходной путь, который выбирают небольшие проекты: обезличенные формулировки вроде «от N символов» и «до N символов» не требуют согласования вовсе. Компромисс рабочий, но от него страдают короткие значения: «от 1 символов» — правдоподобная цена такой экономии. И отдельная ловушка внутри этой темы: min и max на числовом поле говорят не про символы, а про значение — полиморфизм тегов из третьего урока. Один и тот же тег min=1 на строке и на числе требует разных текстов, и каталог, который не различает типы, рано или поздно напишет «минимум 1 символ» про количество детей. Различайте поля по Kind() из пятого урока или разведите уточнения по fieldTexts.

### Числительные: три формы на десять строк

```go
func pluralRu(n int, one, few, many string) string {
        m10, m100 := n%10, n%100
        switch {
        case m10 == 1 && m100 != 11:
                return one // 1 символ
        case m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14):
                return few // 2 символа
        default:
                return many // 5, 11, 20 символов
        }
}

// ветка каталога для min:
case "min":
        n, _ := strconv.Atoi(e.Param())
        return fmt.Sprintf("минимум %d %s", n,
                pluralRu(n, "символ", "символа", "символов"))
```

Три формы русской числительной выбираются по последней цифре и её паре: 1 — единственная, 2-4 — малая, остальное — множественная, с исключением 11-14. Функция принимает формы словами, поэтому работает и для «элементов», и для «минут». Atoi на Param() делает проверку поля-конкретики: параметр тега приходит строкой, а число в шаблон едут уже согласованное.

> **Переименовали поле — уточнение умерло молча.** fieldTexts ключуется по Field() — имени поля структуры. Рефакторинг переименовывает Phone в Contact: компиляция проходит (ключи словаря — строки), рантайм не падает, тесты зелёные — а уточнение перестаёт находиться, и пользователи тихо получают общий текст по тегу. Симптом появляется через недели, когда поддержка спросит, почему формат телефона больше не подсказывается. Страховка — тест, который прогоняет пустую форму и сверяет: для каждого поля из fieldTexts ответ пришёл уточнением, а не общим текстом.

### json-имена полей: RegisterTagNameFunc

Пятый урок оставил шрам: Field() возвращает имя поля структуры, а фронтенд знает json-имя. Пароль в ошибке — «Password», в инпуте — «password», и подсветка на snake_case-фронтах молча не срабатывает. Валидатор решает это штатно: метод RegisterTagNameFunc на экземпляре принимает функцию, которая по reflect.StructField возвращает имя поля — и валидатор дальше использует это имя в Field() и Namespace(). Стандартный вариант — отдавать json-тег. Тут две грабли, на которых спотыкаются все: json-тег с опциями вида "phone,omitempty" вернётся целиком, вместе с опцией, — имя поля получится «phone,omitempty», поэтому тег режут через strings.SplitN по запятой; а поле с json:"-" вернёт минус — имя поля «-» в ошибках не годится, и на минус возвращают пустую строку, чтобы валидатор откатился на имя из структуры. После регистрации маппинг на фронте работает один к одному: Field() говорит json-именами, каталог уточнений по полям ключуется теми же json-именами, а Namespace() строит путь из них — что оживёт в следующем уроке на вложенных структурах. Регистрация — один раз на старте, рядом с созданием экземпляра из второго урока; переключать словарь имён на живом валидаторе нельзя.

### RegisterTagNameFunc: осторожно с опциями

```go
validate := validator.New()
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
        name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
        if name == "-" {
                return "" // json:"-" — вернём имя поля структуры
        }
        return name
})

// было: Field() → "Phone", Namespace() → "SignupForm.Phone"
// стало: Field() → "phone", Namespace() → "SignupForm.phone"
```

SplitN режет json-тег по первой запятой: «phone» остаётся из «phone,omitempty». Пустая строка на минусе возвращает валидатору право назвать поле по-структурному — без этого поля, скрытые из JSON, получили бы имя «-». После регистрации все имена — Field(), Namespace(), имена в ошибках StructLevel — живут по json-меткам, и фронтенд подсвечивает инпуты без маппинга.

### Проверка: что вернёт Field()

**Вопрос:** Поле Phone string с json:"phone" провалило min=10. RegisterTagNameFunc по json-тегу зарегистрирован. Что вернёт e.Field()?

- Phone — имя поля структуры
- ✅ phone — имя из json-тега
- phone,omitempty — json-тег целиком
- Пустую строку — валидатор не знает json

После RegisterTagNameFunc валидатор называет поля json-именами — «phone». Целиком тег вернулся бы без SplitN по запятой — это та самая ловушка с опциями. Имена меняются на уровне всего экземпляра: каталог уточнений по Field() тоже ключуется json-именами, иначе два уровня словаря разъедутся.

### Что получит пользователь

```go
type SignupForm struct {
        Promo string `validate:"omitempty,min=4"`
        Email string `validate:"required,email"`
}

// fieldTexts: {"Email": "адрес почты целиком, с доменом"}
// tagTexts: required → "обязательное поле", email → "укажите адрес почты"

// клиент прислал: Promo "", Email "не-почта"
// что вернёт humanMessage по записям ValidationErrors?
```

**Что выведет программа?**

- Ничего: err == nil, ведь Promo пропущен omitempty
- ✅ Одну строку: «адрес почты целиком, с доменом»
- Две строки: «обязательное поле» и «укажите адрес почты»
- Одну строку: «укажите адрес почты»

Email со значением «не-почта» проходит required (строка непустая) и проваливает email — одна запись. fieldTexts["Email"] найден, уточнение сильнее общего текста «укажите адрес почты». Promo пуст и пропущен omitempty — записи нет. Итог: одна строка, и это уточнение, а не словарная заготовка.

### validator перевод: universalTranslator и готовые словари

Когда API говорит на нескольких языках, словарь на одном языке перестаёт быть решением. Для этого сценария у экосистемы есть тяжёлая артиллерия: пакет universal-translator с менеджером переводов и механизм RegisterTranslation на валидаторе — каждый тег регистрируется с шаблоном и функцией подстановки параметров, а в рантайме сообщения достаются по локали запроса. В валидаторе v10 есть готовые наборы переводов для двух десятков языков, включая русский, — подпакеты translations, где тексты уже написаны сообществом. Прежде чем бежать туда, посчитайте цену: регистрация переводов — отдельный слой инициализации, шаблоны с плейсхолдерами чужого формата, ревью текстов, локали в контексте запроса. Для моноязычного сервиса на русском свой словарь из map — дешевле, прозрачнее и честнее: вы контролируете тон, числительные и уточнения по полям, которых в готовых наборах нет. Мой рабочий ориентир: один язык — свой каталог; два и больше — universalTranslator с готовыми наборами и собственными поверх. И в любом случае тексты — не место для «да» и «нет»: клиенту едут строки без эмоций, но с конкретикой, потому что каждое сообщение — интерфейс к полю формы. Подробный конверт ошибок с кодами и Details соберём в двадцать четвёртом уроке.

### Правила каталога сообщений одним списком

- один каталог на проект, отдельный пакет: хендлеры не пишут тексты вообще
- первый уровень — тексты по Tag(), второй — уточнения по Field() (json-имена после RegisterTagNameFunc)
- уточнение по полю проверяется раньше общего текста — иначе оно никогда не сработает
- ветка default с ActualTag() обязательна: незнакомый тег — техническая строка, а не пустота
- числительные согласуются: 1 символ, 2 символа, 5 символов — или обезличенное «от N»
- переименование поля не ломает каталог молча — тест прогоняет пустую форму и сверяет ветки
- Value() и внутренние имена структур наружу не едут — только Field, Tag, Param и текст

> **Спорное: генерировать тексты автоматически.** Соблазн автогенерации красив: шаблон на каждый тег, отражение по Kind() для «символов против значений», подстановки — и каталог «пишется сам». Я от этого отказался после продукта, где каждое второе поле требовало человеческой формулировки: автогенерация одинаково бездушна везде и сопротивляется уточнениям, а уточнения и есть человечность. Компромисс, который работает: шаблоны для скучного большинства (required, min, max, email) и ручные тексты для полей, где формулировка экономит тикеты поддержки. Условие правоты автогенерации одно: в интерфейсе нет русского UX или тексты — черновик до появления переводчика.

### Соберите функцию-сообщалку

Восстановите тип ошибки, два ключа каталога и страховку в default — четыре пропуска.

```go
func humanMessage(e validator.___) string {
        if t, ok := fieldTexts[e.___()]; ok {
                return t
        }
        t, ok := tagTexts[e.___()]
        if !ok {
                return e.Field() + ": проверка " + e.___() + " не прошла"
        }
        return t
}
```

Подсказка: Тип записи — FieldError; ключ уточнений — Field(); ключ общих текстов — Tag(); страховка в default — ActualTag().

Правильные ответы в пропусках: `FieldError`, `Field`, `Tag`, `ActualTag`

### Соберите путь текста до клиента

Форма сломалась на трёх полях. Разложите путь от валидации до ответа 400.

Правильный порядок строк:

1. `validate.Struct вернул ValidationErrors с тремя записями`
2. `Хендлер перебирает записи: каждая — FieldError`
3. `Для каждой записи вызывается humanMessage из пакета каталога`
4. `Сначала ищется уточнение по полю в fieldTexts`
5. `Нет уточнения — общий текст по Tag() из tagTexts`
6. `Param() подставляется в шаблон: «минимум 8 символов»`
7. `Незнакомый тег уходит в default с ActualTag()`
8. `Клиент получает 400 с Details из русских строк`

### Карточки: слой сообщений

- **двухуровневый каталог** — map по тегам (общие тексты) + map по полям (уточнения). Уточнение по Field() проверяется первым и перекрывает общий текст по Tag().
- **RegisterTagNameFunc** — Функция на экземпляре валидатора, которая переименовывает поля в ошибках. Стандартно — json-тег: Field() и Namespace() начинают говорить json-именами для подсветки на фронте.
- **SplitN(tag, ",", 2)[0]** — Способ достать имя из json-тега с опциями: «phone» из «phone,omitempty». На json:"-" возвращают пустую строку — валидатор откатится на имя структуры.
- **согласование числительных** — 1 символ / 2 символа / 5 символов: выбор по последней цифре с исключением 11-14. Альтернатива — обезличенное «от N символов» без склонения.
- **universalTranslator** — Менеджер переводов для мультиязычных API: RegisterTranslation на тег с шаблоном и подстановкой, выбор текста по локали. Для одного языка дешевле свой map-словарь.
- **ветка default** — Страховка каталога: незнакомый тег отдаёт техническую строку с Field() и ActualTag() вместо пустоты. Дыра в каталоге становится видимой в ответе или логе.

Седьмой урок — [вложенные структуры](/courses/validator/lessons/07-vlozhennye-struktury/): адрес внутри профиля, рекурсивная проверка дерева и Namespace() из нескольких сегментов, на который сейчас опираются уточнения каталога. Каталог в [двенадцатом уроке](/courses/validator/lessons/12-validator-i-gin/) переедет в Gin вместе с тегами binding, а единый конверт ошибок API с кодами — тема [двадцать четвёртого](/courses/validator/lessons/24-soobshcheniya-api/). Каталог из этого урока соберите локально: песочница сайта исполняет только чистый Go без внешних модулей, поэтому валидатор и его сообщения живут в вашем терминале, а интерактивы закрепляют механику без запуска.

## Коротко: что запомнить

- Сообщения собираются в отдельный пакет-каталог: хендлеры только вызывают функцию-сообщалку, тексты не живут в коде эндпоинтов.
- Два уровня каталога: общий текст по Tag() и уточнение по Field(); уточнение проверяется первым.
- Ветка default с ActualTag() обязательна: незнакомый тег отвечает технической строкой, а не молчанием.
- Русские числительные склоняются: функция на три формы или обезличенное «от N символов».
- RegisterTagNameFunc переименовывает поля в ошибках по json-тегу; SplitN режет опции, минус откатывается на имя структуры.
- Для мультиязычных API — universalTranslator и RegisterTranslation; для одного языка дешевле свой словарь.
- Переименование поля убивает уточнение молча — тест на пустую форму сверяет ветки каталога.

## Домашнее задание

Возьмите mini-каталог из пятого урока и доведите до двухуровневого: tagTexts для required, min, max, email, oneof и fieldTexts минимум для двух полей вашей формы. Добавьте pluralRu и ветку min с согласованием числительных. Напишите тест, который прогоняет пустую форму и проверяет: каждое поле из fieldTexts отвечает уточнением. Потом переименуйте одно поле в структуре и убедитесь, что тест покраснел — вы только что защитили каталог от молчаливой смерти.

## Вопросы и ответы по уроку

### Почему бы не править err.Error() регуляками по ходу?

Простыня Key: '...' — это Error() каждой записи, склеенный переводами строк; парсить его регуляками значит зависеть от формата чужой строки, который меняется между мажорными версиями. Каталог по Tag() и Field() опирается на методы, а не на формат текста, и переживает обновления библиотеки.

### Где ключиться уточнения — по Field() или по Namespace()?

На плоских структурах одинаково. На вложенных Field() возвращает только последний сегмент, и два поля City в разных структурах съедят уточнение друг друга — там ключуют по Namespace(). Условие простое: как только появились вложенные структуры, переходите на полный путь, об этом следующий урок.

### Стоит ли хранить тексты в YAML или базе?

YAML — да, если каталог большой или редактируется без релиза: ключи те же, загрузка — на старте. База — обычно нет: сообщения меняются вместе с правилами в коде, а запрос в базу на каждое сообщение в hot path — цена неоправданная. Если правила в базе, кэшируйте словарь в памяти и обновляйте по сигналу.
