---
title: "Свой валидатор-функция"
description: "registervalidation: кастомный валидатор go — функция-тег с Field и Param, nil-поля и третий аргумент, RegisterAlias, чёрный список и тесты правил."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/10-kastomnye-funkcii/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Свой валидатор-функция

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

RegisterValidation превращает вашу функцию в полноценный тег: кастомный валидатор go выглядит в структуре как родной — validate:"phone" — и проверяет то, для чего встроенных тегов нет: телефонные маски, артикулы, чёрные списки логинов. В этом уроке пишем свои функции, читаем Field и Param, разбираемся с nil-полями и третьим аргументом и узнаём, когда достаточно RegisterAlias без кода.

Четвёртый урок оставил обещание: форматов в валидаторе много, но бизнес-форматы бесконечны, и телефон с кодом страны туда не влез. Часть таких случаев закрывает e164, часть — не закрывает ничем: артикул по внутреннему стандарту, логин из чёрного списка, сумма, кратная номиналу. Для них у валидатора есть RegisterValidation — функция регистрируется на экземпляре и становится тегом с собственным именем. Внутри функции — обычный Go: регулярки, словари, любые проверки без сетевого IO; снаружи — обычный тег, неотличимый от required и email. Это последняя из базовых механик курса: после неё теги перестают быть чужой грамматикой и становятся вашим API.

### RegisterValidation: своя функция становится тегом

Регистрация — метод на экземпляре валидатора: RegisterValidation("phone", fn), где fn — функция типа validator.FieldLevel, возвращающая bool. С этого момента тег phone работает в любой структуре этого экземпляра как встроенный: встанет в цепочку правил, подчинится omitempty и условным тегам, попадёт в FieldError с собственным именем. Регистрировать нужно на старте, до первых проверок: реестр правил не потокобезопасен во время валидации — та же дисциплина второго урока, где один экземпляр живёт на всё приложение. Место регистрации тоже выбирайте один раз: пакет, где создаётся экземпляр, обычно рядом с конструктором — так ревьюер видит весь словарь тегов проекта на одном экране, а тесты собирают тот же набор вызовов без копипасты. Реестр ведёт себя иначе, чем подсказывает интуиция. Повторная регистрация того же имени не падает: документация прямо говорит — если тег уже существует, предыдущая функция заменяется новой. Тихая замена опаснее паники: два пакета, зарегистрировавшие один тег по-разному, дают валидатор, который работает по правилам того, кто инициализировался последним, — поэтому держите все RegisterValidation в одном месте, где конфликт виден на ревью. Жёстко защищены служебные теги: попытка зарегистрировать required, omitempty, dive, keys, endkeys или structonly падает паникой «Tag 'required' either contains restricted characters or is the same as a restricted tag needed for normal operation» — они грамматика разбора тега, и валидатор их не отдаёт. Остальные встроенные — min, max, email — формально перезаписываются без ошибки: не приглашение, а дыра, в которую легко наступить; своё поведение называйте своим именем. Имя тега выбирайте как имя функции: латиница, без пробелов и запятых — грамматика тега не оставляет места для вольностей. И держите функцию чистой: предикат, который берёт значение и отвечает bool, без изменения состояния, без времени и сети — причины разобраны ниже, а пока договорённость.

### Тег phone: регулярка за десяток строк

```go
var phoneRe = regexp.MustCompile("^[+][0-9]{7,15}$")

validate := validator.New()
validate.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
        return phoneRe.MatchString(fl.Field().String())
})

type ContactForm struct {
        Phone string `validate:"required,phone"`
}

// "+79161234567" → проходит: плюс и 11 цифр
// "89161234567"  → провал: тег phone, Param пуст
```

Регулярка скомпилирована один раз на уровне пакета — компиляция в самой функции означала бы пересборку выражения на каждое поле. fl.Field() возвращает reflect.Value поля, String() читает строку. required стоит первым: nil и пустоту он ловит до функции, поэтому внутри можно не думать о нуле. Провал попадает в FieldError с Tag() равным phone — имя своё, механика чужая.

### Проверка: двойная регистрация

**Вопрос:** На одном экземпляре валидатора RegisterValidation("phone", fn) вызвали второй раз с другой функцией. Что произойдёт?

- ✅ Новая функция молча заменит старую: тег уже существует
- Паника на месте вызова: тег уже зарегистрирован
- Валидатор вернёт ошибку при первой проверке структуры
- Вторая регистрация игнорируется: останется первая функция

Документация прямо говорит: если тег уже существует, предыдущая функция заменяется — паники и ошибки нет. Паникой защищены только служебные теги вроде required, omitempty и dive: попытка зарегистрировать их падает сообщением про restricted tag прямо на старте. Поэтому все регистрации — в одном месте пакета: тихая замена видна там, а не в проде.

### FieldLevel: что можно читать в функции

| Метод | Что отдаёт |
| --- | --- |
| Field() | reflect.Value поля: String(), Int(), Kind(), IsNil() |
| Param() | параметр тега строкой — всё после знака равенства |
| FieldName() | имя поля; после RegisterTagNameFunc — json-имя |
| StructFieldName() | имя поля из объявления структуры — даже после RegisterTagNameFunc |
| Parent() | родительская структура — соседние поля одним движением |
| Top() | корневая структура вызова Struct() |
| GetTag() | имя тега, по которому вызвали функцию |

### Параметр тега: fl.Param() и гибкие правила

Тег может нести параметр после знака равенства — это fl.Param() внутри функции, строка без трюков: «5» для multiple=5, перечень через пробел для списка значений. Параметр — способ сделать один тег семейством правил вместо десяти тегов на каждый случай: multiple=5 и multiple=100 обслуживаются одной функцией, а если завтра бизнес попросит кратность двадцати, тег в структуре изменится без правки кода проверки — в этом весь смысл параметризации. Механика чтения честная: Param() приходит строкой, и разбор — ваша работа — strconv.Atoi с честной обработкой ошибки. Ошибка параметра — это ошибка программиста, не данных: тег написали руками, и «5о» в нём обнаружится на первом же тесте; функция возвращает false, и провал всплывает с именем тега. Внутри функции стоит дисциплина отражения: прежде чем звать Int() или String(), проверьте Kind() — тег, повешенный не на то поле, обязан честно провалиться, а не поднять панику reflect. Для значений-перечней держите грамматику из третьего урока: пробел внутри параметра разделяет значения, запятая между правилами — конвейер. И не забывайте Parent() из таблицы: функция видит родительскую структуру, поэтому «сумма кратна номиналу, но не больше остатка» может читаться из соседнего поля — а полноценные кросс-польные контракты это мост к StructLevel из одиннадцатого урока.

### Два тега: замыкание и параметр

```go
// Замыкание: чёрный список загружен на старте из конфига.
var reserved = map[string]bool{"admin": true, "root": true}

validate.RegisterValidation("notreserved", func(fl validator.FieldLevel) bool {
        return !reserved[fl.Field().String()]
})

// Параметр после знака равенства приходит в fl.Param().
validate.RegisterValidation("multiple", func(fl validator.FieldLevel) bool {
        if fl.Field().Kind() != reflect.Int {
                return false // тег повесили не на то поле
        }
        n, err := strconv.Atoi(fl.Param())
        if err != nil || n == 0 {
                return false // битый параметр — честный провал
        }
        return fl.Field().Int()%int64(n) == 0
})

// Username string `validate:"required,notreserved,min=3"`
// Amount    int    `validate:"required,multiple=5"`
```

notreserved читает замыкание: словарь загружен на старте, функция остаётся чистым предикатом без IO. multiple разбирает Param() через strconv и дисциплину Kind: не-число и не-целое поле проваливаются явно, а не паникуют. Оба тега используются как встроенные — цепочка правил и omitempty работают без оговорок.

> **Живое место: запрос в базу внутри валидатора.** Соблазн зашить в тег «логин не занят» с проверкой по базе: функция же может всё. В час пик каждая регистрация ставит сетевой вызов внутрь рефлексивного цикла валидации: таймауты базы превращаются в 400, ретраи дублируют запросы, тесты требуют живой БД, а гонка на общем кэше функции ловится по ночам. Правило жёсткое: валидатор — чистые предикаты; данные для проверки грузите на старте в замыкание, а проверки живого состояния — «логин занят», «купон истёк» — оставляйте бизнес-слою после валидации. У них другой контракт ошибок и другие ретраи.

### nil-поля и третий аргумент

У собственных функций есть тихое соглашение, которое проявляется только на указателях и интерфейсах: по умолчанию валидатор НЕ вызывает вашу функцию на nil-поле. Указатель пуст — правило пропускается, поле считается пройденным, и это выглядит логично: проверять нечего. Но молчаливый пропуск — источник недоумений: тег есть, а проверки как будто нет. Если функция обязана видеть даже nil — например, чтобы различать «не указано» и «указано пусто» — у RegisterValidation есть третий вариадический аргумент: RegisterValidation("phone", fn, true) вызывает функцию и на нулевых полях, и внутри вы сами решаете, что с ними делать, через fl.Field().IsNil(). Комбинации с остальными тегами при этом обычные: required перед вашим тегом отловит nil и не пустит выполнение дальше, omitempty пропустит пустоту по договору опциональности. Практический вывод: свой тег на указателе — всегда случай из трёх, и тест должен показать, какой из них ваш: nil пропущен, nil проваливается, nil передаётся в функцию. Третий аргумент выручает там, где nil — легитимное значение с собственным смыслом: «не указано» в полях отчётности, дата без времени, опциональная ссылка на документ. Без теста разница живёт только в голове автора, а после рефакторинга живёт там неправильно.

### Что вернёт проверка профиля

```go
type Profile struct {
        Nickname string  `validate:"omitempty,notreserved"`
        Backup   *string `validate:"notreserved"`
}

p := Profile{Nickname: "", Backup: nil}
err := validate.Struct(&p)
// что в err?
```

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

- ✅ nil: Nickname пропущен omitempty, а nil-Backup функция не увидела
- Ошибка notreserved по Backup: nil не в чёрном списке
- Ошибка required по Backup: поле обязательно
- Паника: функция разыменовала nil-строку

Nickname пуст — omitempty пропустил notreserved. Backup nil: по умолчанию своя функция на нулевом поле не вызывается, правило пропускается — ошибки нет. Регистрируйте notreserved с третьим аргументом true, если nil должен проверяться, или ставьте required, если nil недопустим. Паники нет: до разыменования дело не дошло.

### RegisterAlias: композиция вместо кода

Прежде чем писать функцию, спросите: а не собирается ли мой тег из существующих правил? RegisterAlias регистрирует новое имя для готовой комбинации: RegisterAlias("phone", "e164") — и тег phone означает встроенный формат E.164, без единой строки логики. Значение алиаса — готовая строка правил: с параметрами и с разделителем | для варианта «любое из», как в классическом примере с цветовыми форматами. Алиас не проверяет ничего сам — он словарь имён, и в этом его сила: правило меняется в одном месте, а теги по проекту остаются. С алиасами связана тонкость из пятого урока: Tag() вернёт ваше имя — phone, а ActualTag() — реальный тег e164; каталог сообщений ключуйте по Tag(), чтобы сообщение «телефон в формате +7...» жило под именем, которое видит ревьюер. Регистрация алиасов — на том же старте и с той же не-потокобезопасностью, что у RegisterValidation. Правило выбора простое: комбинация существующих правил — алиас; новая логика, которой нет среди встроенных тегов — функция. Если валидатору можно объяснить телефон через e164 — не пишите регулярку, [форматы из четвёртого урока](/courses/validator/lessons/04-email-url-format/) уже проверены сообществом.

### Проверка: параметр в FieldError

**Вопрос:** Amount int с тегом multiple=5 провалил проверку. Что вернёт e.Param()?

- ✅ 5 — значение параметра тега
- multiple — имя тега
- Пустую строку: у своих тегов нет параметров
- 0 — провалившееся значение поля

Param() — это всё, что стоит после знака равенства в теге, независимо от того, встроенный это тег или ваш. multiple — Tag(), ноль поля — Value(). Каталог сообщений строит «сумма должна быть кратна 5» ровно из этой пары: Tag() выбирает шаблон, Param() подставляет число.

### Сообщения для своих тегов и тесты правил

Свой тег попадает в FieldError со своим именем — и каталог из шестого урока должен его знать: ветка case "phone" в tagTexts с формулировкой «телефон в формате +7XXXXXXXXXX». Без ветки сообщение уедет в default с технической строкой — тот же признак дыры в каталоге, что и у встроенных тегов. Тесты правил — вторая половина дисциплины: тег — строка в структуре, компилятор его не проверяет, а ваша функция проверяет только то, что ей дали. Пятёрка значений из третьего урока здесь работает в полный рост: валидное значение, валидное на границе, невалидный формат, пустое, nil для указателей — и к ним стоит добавить случай «тег повешен не на тот тип», если функция проверяет Kind. Табличные тесты для правил — готовый каркас из [девятнадцатого урока](/courses/validator/lessons/19-testy-validacii/), а регистрация в тестах использует тот же экземпляр, что и приложение. Имена тегов и ветки каталога стоит проверять одной связкой: тест, который прогоняет все зарегистрированные теги через пустую форму, заодно проверяет и полноту каталога — две защиты одной строкой. Отдельный тест на Param() не повредит: битый параметр сегодня — это false на всех полях сразу, и лучше узнать об этом в CI, чем в тикете «всё сломалось с обновлением».

### Правила своих валидаторов одним списком

- функция — чистый предикат bool: без IO, без времени, без состояния запроса
- регистрация — на старте, до первых проверок, в одном месте пакета
- повторная регистрация молча заменяет функцию; служебные теги вроде required и dive — паника
- fl.Field() — reflect.Value: проверяйте Kind(), прежде чем звать String() и Int()
- Param() — одна строка после знака равенства; перечни — через пробел внутри
- по умолчанию nil-поле функцию не вызывает; третий аргумент true меняет контракт
- комбинация существующих правил — RegisterAlias, новая логика — функция
- каждому тегу — ветка в каталоге сообщений и тест с пятёркой значений

> **Спорное: не превращайте валидатор в скриптовый движок.** Признак болезни: RegisterValidation на каждое второе поле, а внутри — повторение min, max и oneof. Я считаю, свой тег оправдан только для бизнес-формата, которого нет среди встроенных: телефонные маски, артикулы, ИНН с контрольной суммой, логин вне резерва. Условие, при котором я готов писать больше: функция убирает повторяющуюся логику минимум из трёх мест и не дублирует встроенный тег. Остальное — встроенные теги и алиасы; иначе через год проект носит собственный диалект валидации, который новому человеку придётся учить вместо известной грамматики.

### Соберите регистрацию тега

Четыре пропуска: конструктор, регистрация, вызов регулярки и чтение поля.

```go
validate := validator.___()
validate.___("phone", func(fl validator.FieldLevel) bool {
        return phoneRe.___(fl.Field().___())
})
```

Подсказка: Конструктор — New; регистрация тега — RegisterValidation; проверка строки регуляркой — MatchString; чтение значения — String().

Правильные ответы в пропусках: `New`, `RegisterValidation`, `MatchString`, `String`

### Соберите путь своего тега

Логин «admin» приходит на регистрацию. Разложите путь от старта приложения до ответа.

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

1. `На старте приложения чёрный список загружен из конфига`
2. `RegisterValidation("notreserved", fn) зарегистрировал функцию`
3. `Приходит запрос: Username "admin"`
4. `Хендлер декодирует форму и вызывает validate.Struct`
5. `Движок встречает тег notreserved и зовёт функцию`
6. `Функция проверяет замыкание: admin в чёрном списке`
7. `FieldError с Tag() notreserved и Field() Username`
8. `Каталог отвечает «логин занят», ответ 400 уходит клиенту`

### Карточки: свои теги

- **RegisterValidation** — Метод экземпляра: RegisterValidation("phone", fn) делает функцию тегом. Регистрация на старте; повторная — тихая замена функции, а служебные теги вроде required защищены паникой.
- **FieldLevel** — Контекст вызова функции: Field() — reflect.Value поля, Param() — параметр после =, Parent() — родительская структура, GetTag() — имя тега.
- **fl.Param()** — Параметр тега строкой: «5» для multiple=5, перечень через пробел. Разбор — ваша работа: strconv и честная обработка битого параметра.
- **третий аргумент** — RegisterValidation("phone", fn, true) вызывает функцию даже на nil-полях. По умолчанию nil-поле пропускается без вызова — тест обязан показать, какой контракт ваш.
- **RegisterAlias** — Новое имя для комбинации существующих правил: RegisterAlias("phone", "e164"). Без кода; Tag() вернёт алиас, ActualTag() — реальный тег.
- **чистый предикат** — Функция тега не ходит в сеть, не читает время и не меняет состояние. Живые проверки — «логин занят» — бизнес-слой после валидации, с другим контрактом ошибок.

Одиннадцатый урок — [StructLevel: правила на всю структуру](/courses/validator/lessons/11-structlevel/): когда «сумма позиций равна итогу» не лезет ни в один тег поля, начинается валидация целого объекта. Форматы, на которых строились алиасы, разобраны в [четвёртом уроке](/courses/validator/lessons/04-email-url-format/), каталог сообщений — в [шестом](/courses/validator/lessons/06-russkie-soobshcheniya/). Код тега phone и чёрного списка соберите локально: песочница сайта исполняет только чистый Go без внешних модулей, поэтому свои валидаторы живут в вашем терминале, а интерактивы проверяют контракт функций без запуска.

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

- RegisterValidation превращает функцию в тег: имя своё, механика встроенная; повторная регистрация молча заменяет функцию — держите теги в одном месте.
- FieldLevel даёт Field(), Param(), Parent() и Top(): значение, параметр, соседние поля и корень структуры.
- Параметр тега — строка в Param(): разбор strconv, дисциплина Kind, битый параметр — честный провал.
- Функция — чистый предикат: IO, время и состояние запроса остаются бизнес-слою; данные для проверки — в замыкании на старте.
- По умолчанию функция на nil-поле не вызывается; третий аргумент true заставляет её видеть и nil.
- Комбинация существующих правил — RegisterAlias без кода; новая логика — функция.
- Свой тег обязан иметь ветку в каталоге сообщений и табличный тест с пятёркой значений.

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

Реализуйте тег inn: функция проверяет десять цифр и контрольное число по алгоритму ИНН физлица. Зарегистрируйте на экземпляре из второго урока, добавьте ветку в каталог сообщений и табличный тест: валидный ИНН, валидный на границе контрольного числа, невалидная контрольная сумма, буквы в строке, пустая строка, nil на *string. Потом напишите RegisterAlias("phone", "e164") и объясните себе, когда ваш телефонный тег из урока честнее алиаса.

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

### Можно ли перезаписать встроенный тег своей функцией?

Служебные теги — required, omitempty, dive, keys, endkeys, structonly — нет: RegisterValidation падает паникой «Tag ... either contains restricted characters ...». Остальные встроенные, вроде min и email, перезапишутся молча, но делать этого не стоит: код, читающий FieldError по Tag(), разъедется с фактическими правилами. Регистрируйте новое имя, а для «телефон — это e164» проще RegisterAlias с новым именем: он не пишет логики вовсе.

### Почему функция не вызывается на пустой строке?

Соглашение касается nil: на nil-указателях и nil-интерфейсах функция не вызывается, а пустая строка — обычное значение, и функция её получает. Если хотите пропускать пустые строки до своей проверки, ставьте omitempty перед тегом — это обычный конвейер правил из третьего урока.

### Где жить правилу «логин уже занят»?

Не в валидаторе. Это вопрос живого состояния, а не формата: он требует запроса к базе, своих ретраев и своего контракта ошибок — обычно 409 вместо 400. Валидатор проверяет форму значения, бизнес-слой — занятость; смешение даёт таймауты базы внутри рефлексивного цикла и 400 на сетевые сбои.
