---
title: "Вложенные структуры: Validate рекурсией"
description: "validator nested: рекурсивная проверка дерева — required на указателе, omitempty, structonly, Namespace с полным путём и мост к dive для срезов структур."
course: "Валидация в Go: go-playground/validator"
url: "https://golangmaster.ru/courses/validator/lessons/07-vlozhennye-struktury/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Вложенные структуры: Validate рекурсией

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

validator nested-структуры проверяет сам, без единого дополнительного тега: встретив поле-структуру, Struct спускается внутрь и собирает ошибки со всего дерева, а Namespace() показывает полный путь до сломанного поля. В этом уроке — адрес внутри профиля: указатели против значений, required у вложенного объекта, structonly, json-имена в пути и мост к dive для срезов структур.

До сих пор формы были плоскими: string и int на верхнем уровне. Настоящие формы слоистые — профиль несёт адрес, заказ несёт контакт и доставку, платёж несёт карту. Интуиция подсказывает, что для вложенности нужен какой-то тег вроде deep или nested — и интуиция ошибается: валидатор проверяет дерево из коробки. Встретив поле, чей тип — структура, он спускается внутрь и проверяет её поля по их тегам, рекурсивно на любую глубину. Новая здесь не механика, а два вопроса, которые раньше не вставали: как ошибке внутри дерева объяснить, откуда она (ответ — Namespace() из пятого урока) и как сказать «сам объект обязателен» (ответ — указатель, и тут есть ловушка, на которой теряются даже опытные).

### Вложенные структуры валидация: рекурсия из коробки

Разберём механику на профиле. ProfileForm содержит Name и Address, а Address содержит City, Street и ZipCode со своими тегами. Один вызов validate.Struct(&form) проверяет всё: Name по своим правилам, затем движок видит, что Address — структура, и обходит её поля по их тегам. Вложенные структуры валидация проваливает не одним обобщённым «Address сломан», а конкретными записями по полям: City провалил required, ZipCode провалил len=6. И вот здесь оживёт Namespace(), о котором пятый урок сказал «запомните сам факт»: для City путь будет ProfileForm.Address.City — полный маршрут от корня через точки. Field() при этом возвращает только последний сегмент, «City», — этого хватает для каталога уточнений на плоских структурах и не хватает на вложенных: два поля City в разных адресах съедят уточнение друг друга, поэтому на деревьях каталог ключуют по Namespace(). Проверки каждого уровня независимы: три сломанных поля на трёх глубинах дадут три записи в ValidationErrors, порядок следует порядку полей структуры. Обход дерева предсказуем: движок идёт по полям в порядке объявления и спускается внутрь поля-структуры сразу, как до него доходит очередь — глубина раньше соседей. Записи в ValidationErrors поэтому читаются сверху вниз по дереву, и в логе сообщения про вложенные объекты не перемешиваются хаотично. Цена рекурсии — отражение — платится из того же кэша планов по reflect.Type из второго урока: дерево разбирается один раз за жизнь процесса, повторные вызовы работают по готовому плану.

### Профиль с адресом: одна проверка на всё дерево

```go
type Address struct {
        City    string `validate:"required,min=2"`
        Street  string `validate:"required"`
        ZipCode string `validate:"required,len=6"`
}

type ProfileForm struct {
        Name    string  `validate:"required,min=3,max=64"`
        Address Address `validate:"required"`
}

// Address{Street: "Тверская", ZipCode: "123"}:
//   Namespace()                  Field()   Tag()
//   ProfileForm.Address.City     City      required
//   ProfileForm.Address.ZipCode  ZipCode   len
```

Один вызов Struct проверил поле верхнего уровня и весь вложенный объект: ошибки приехали по конкретным полям адреса, а не по Address целиком. required на Address здесь — иллюстрация ловушки из следующего блока: на структуре-значении он ничего не проверяет. Namespace() складывает маршрут через точки — это и есть материал для каталога уточнений и для подсветки на фронте.

### Проверка: полный путь ошибки

**Вопрос:** В ProfileForm.Address поле City провалило required. Что вернёт e.Namespace()?

- City — Namespace() совпадает с Field()
- Address.City — путь без имени корневой структуры
- ✅ ProfileForm.Address.City — полный путь от корня
- Пустую строку — путь есть только у плоских структур

Namespace() строит маршрут от корневой структуры до сломанного поля через точки: ProfileForm.Address.City. Field() даёт только последний сегмент — City. Полный путь нужен каталогу уточнений на деревьях и фронтенду, который подсвечивает вложенные инпуты по маршруту, а не по имени.

### Указатель или значение: required у вложенного объекта

Главный вопрос вложенности: как сказать «объект целиком обязателен». Ответ прячется в семантике required из третьего урока — поле не должно быть нулевым значением своего типа. Структура-значение нулевой не бывает: она всегда существует, пусть и заполненная нулями, поэтому required на Address Address проходит всегда и не проверяет ничего. Это не баг, а задокументированная ветка обратной совместимости: валидатор явно пропускает required на не-указательной структуре — в том числе и полностью нулевой. Опция WithRequiredStructEnabled() включает строгую семантику: required начинает проваливать нулевую структуру-значение, и именно она станет поведением по умолчанию в следующей мажорной версии — закладывайтесь на указатели уже сейчас, чтобы миграция не изменила вам договоры. Требование «объект должен прийти» выражается указателем: *Address с required означает «не nil», и nil-указатель провалит проверку с честным тегом required по имени поля. Вторая половина механики — что происходит с nil без required: валидатор пропускает такой объект целиком, внутрь не спускается, ошибки по полям не собирает. Это разумно — проверять поля отсутствующего объекта нечем, — но пропуска должен хотеть именно вы, а не случайность декодирования. omitempty на указателе делает намерение явным: «объекта может не быть, а если есть — проверяй поля». Бонус связки знаком по третьему уроку: после успешной проверки *Address с required указатель гарантированно не nil, и разыменование в бизнес-коде безопасно без второго if. И разбор StructPartial из второго урока здесь добирает своё: частичная проверка принимает вложенные пути вроде Address.City, потому что сравнивает перечисленные поля с теми же Namespace().

### Поведение поля-структуры по тегам

| Описание поля | Значение nil | Значение непустое |
| --- | --- | --- |
| T — значение, без тегов | невозможно | рекурсия по полям |
| *T — указатель, без тегов | пропускается целиком | рекурсия по полям |
| *T required | ошибка по required | рекурсия по полям |
| *T omitempty | пропускается целиком | рекурсия по полям |
| T structonly | — | поля не проверяются |
| любое с validate:"-" | пропускается | пропускается |

### Три вложенных объекта, три договора

```go
type CheckoutForm struct {
        Contact  Contact  `validate:"required"`  // структура-значение
        Delivery *Address `validate:"omitempty"` // объект опционален
        Invoice  *Address `validate:"required"`  // объект обязателен
}

// Delivery == nil         → пропущена: поля не проверялись
// Delivery == &Address{}  → рекурсия: City провалит required
// Invoice  == nil         → ошибка по required: заказ без счёта нельзя
// Contact без адреса?     → required всегда зелёный, ошибки — по полям
```

Три поля — три разных договора с клиентом. Contact как значение означает «контакт есть всегда», и его честность держится на тегах внутренних полей, а не на required. Delivery — опциональный объект: nil пропускается, непустой проверяется рекурсией. Invoice — обязательный: nil проваливает required, и разыменование после проверки безопасно.

> **omitempty на объекте, от которого зависит бизнес.** Кейс из корзины: Delivery *Address с omitempty — логично, «доставка опциональна». Через релиз появляется самовывоз, и старый мобильный клиент перестаёт слать delivery вовсе: валидатор молчит, заказ принимается, а адреса доставки нет — курьеру нечего передавать. Обнаруживается на складе, а не в тестах, потому что валидация честно сделала то, о чём её попросили. Мораль: omitempty на вложенном объекте — коммит о бизнесе, а не о синтаксисе; объект, без которого логика не работает, обязан быть required, а ветки «или самовывоз» описываются условными тегами из девятого урока.

### structonly и "-": управляем глубиной проверки

Рекурсия из коробки — это удобно ровно до тех пор, пока дерево совпадает с границей проверки. В реальных системах встречаются поля-структуры, которые проверять не нужно: объект уже прошёл проверку на другой границе — пришёл из очереди после валидации продюсера или из соседнего сервиса, который гарантировал целостность; вложенная структура описывает данные, которые этот эндпоинт не трогает; либо дерево настолько большое, что повторная проверка хвоста на каждом запросе — чистая трата. Для таких полей есть тег structonly: поле остаётся под валидацией как структура, но спуск в его поля не выполняется — правила внутри игнорируются, отрабатывают только StructLevel-правила самой структуры, о которых одиннадцатый урок. Полное отключение поля даёт validate:"-": валидатор не проверяет ни поле, ни его содержимое — приём для полей, которые живут в структуре ради сериализации. Различие стоит держать в голове, потому что они решают разные задачи: structonly говорит «поле проверяется как единое целое», минус — «поле вне контракта проверки». Выбирайте structonly, когда гарантии объекта у вас всё-таки есть и вы готовы назвать, откуда, — иначе это способ замести дыру в границе доверия под ковёр.

### Что вернёт проверка заказа

```go
type Order struct {
        Buyer *Contact `validate:"omitempty"`
        Note  string   `validate:"omitempty,max=100"`
}

o := Order{Buyer: nil, Note: ""}
err := validate.Struct(&o)
// что в err?
```

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

- ✅ nil: Buyer nil пропущен omitempty, Note пуст пропущен omitempty
- Ошибка по Buyer: required — объект обязателен
- Ошибка по Note: пустая строка короче минимума
- Паника: валидатор разыменовал nil-указатель

Оба поля опциональны и оба нулевые: Buyer равен nil, omitempty пропускает проверку, внутрь указателя движок не спускается; Note пуст, omitempty пропускает max. Проверка прошла без единой ошибки. Замените omitempty на required у Buyer — появится ошибка по тегу required, а не по полям Contact.

### json-имена в Namespace: путь для фронтенда

Шестой урок зарегистрировал RegisterTagNameFunc по json-тегам — на плоских структурах эффект был косметическим, на деревьях он становится архитектурным: каждый сегмент пути переименовывается, и Namespace() превращается из ProfileForm.Address.City в маршрут json-имён вроде profile.address.city. Для фронтенда это готовый контракт: ответ 400 несёт путь, фронт разбивает его по точкам и подсвечивает вложенный инпут без таблиц соответствий. Отсюда же практическое правило каталога: на вложенных структурах уточнения ключуются по Namespace(), потому что Field() отдаёт только последний сегмент — City в Address и City в OfficeAddress различит только полный путь. Есть и цена, о которой честно: путь — это строка, и переименование json-тега меняет контракт с фронтом так же, как переименование поля меняло каталог уточнений в прошлом уроке; страховка та же — тест на пустую форму, который проверяет, что путь ожидаемого вида доезжает до ответа. И ещё деталь, которую удобно знать заранее: вложенность и нормализация имён не конфликтуют — сегменты собираются по тем же правилам, что и плоские поля, поэтому поведение предсказуемо на любой глубине. Пример на практике: уточнение «город — только Москва» для Address и «город отделения — для справки» для OfficeAddress записываются двумя ключами с общим префиксом и никогда не конфликтуют, потому что полный путь различает оба поля целиком; на плоской структуре такой конфликт не смог бы возникнуть, поэтому правило ключуют по Namespace вместе с первой вложенностью.

### Проверка: какой ключ для уточнения

**Вопрос:** Профиль содержит Address и OfficeAddress, в обоих есть поле City с json:"city". Какому ключу в каталоге уточнений доверить текст про город?

- Field() — «City»: он уникален внутри каждой структуры
- ✅ Namespace() — «Profile.Address.City» и «Profile.OfficeAddress.City»
- Tag() — «required»: город всегда ломается им
- Имени структуры — «Address» и «OfficeAddress»

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

### Срез структур: dive коротким мостом

Последний вопрос плана — что делать, когда вложенных объектов много: Addresses []Address в профиле или позиции []Item в заказе. Ответ: элементы среза валидатор сам не трогает — срез это контейнер, и по умолчанию проверяются его правила уровня контейнера вроде min и max на количество элементов. Чтобы спуститься в каждый элемент, между правилами ставится тег dive, и после него правила применяются уже к каждому элементу — а элемент здесь структура, значит, включается та же рекурсия из этого урока. Ошибки получают индексированные пути: Addresses[0].City, Addresses[1].ZipCode — фронт подсвечивает конкретную строку формы, а не весь блок. Пара тонкостей, которые обычно спрашивают сразу: пустой срез под dive не проверяет ничего — итераций ноль, и это не ошибка; nil-элемент в срезе указателей без required пропускается по правилам прошлого блока. Для длинных срезов полезно помнить, что ошибки не ограничиваются первым провалом: список из двадцати позиций с тремя сломанными вернёт три записи, и все три доедут до Details одним ответом — повторная проверка после исправления покажет следующий список, если что-то осталось. Полная механика dive — правила до и после разделителя, unique, карты с keys и endkeys, цена поэлементной проверки — отдельная тема следующего урока, здесь достаточно моста: вложенность работает и внутри элементов, просто перед ней стоит dive.

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

- вложенные структуры проверяются рекурсией без дополнительных тегов — на любую глубину
- Field() — последний сегмент пути, Namespace() — полный маршрут через точки
- required на структуре-значении ничего не проверяет; «объект обязателен» — это *T с required
- nil-указатель без required пропускается целиком; omitempty делает намерение явным
- после успешной проверки *T с required разыменование безопасно
- structonly останавливает спуск в поля, validate:"-" выключает поле целиком
- на деревьях каталог уточнений ключуют по Namespace(), а не по Field()
- для срезов структур перед рекурсией стоит dive — темы следующего урока

> **Спорное: плоские DTO против вложенных структур.** Моё рабочее мнение: для простых форм вложенность — лишняя. Форма профиля из трёх полей не обязана нести объект Address ради двух строк — плоский DTO с City и Street коротче, ошибки читаются без разбора пути, а фронтенд подсвечивает инпуты без конвертов маршрутов. Условие, при котором я меняю мнение: данные иерархические по смыслу — адрес повторяется, позиций заказа много, вложенный объект едет дальше по системе как единое целое. Тогда вложенность отражает домен, а не вкус разработчика, и рекурсивная проверка окупается мгновенно. Хуже всего золотая середина: вложенность ради «красивого JSON» в форме, где глубина не отражает ничего.

### Соберите договоры вложенности

Три пропуска: опциональный объект, обязательный объект и поле, которое проверено выше по потоку.

```go
type CheckoutForm struct {
        Delivery *Address `validate:"___"` // объекта может не быть
        Invoice  *Address `validate:"___"` // без объекта заказ не оформить
        Legacy   Address  `validate:"___"` // поля проверены на другой границе
}
```

Подсказка: Опциональный объект — omitempty; обязательный — required на указателе; поле без спуска во внутренние правила — structonly.

Правильные ответы в пропусках: `omitempty`, `required`, `structonly`

### Соберите путь ошибки по дереву

Клиент отправил профиль с пустым City и коротким ZipCode. Разложите события валидации по порядку.

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

1. `Клиент отправляет JSON с вложенным объектом address`
2. `Хендлер декодирует тело в ProfileForm с полем Address`
3. `validate.Struct проверяет Name по тегам верхнего уровня`
4. `Движок видит поле-структуру Address и спускается внутрь рекурсией`
5. `City проваливает required внутри Address`
6. `ZipCode проваливает len=6 внутри Address`
7. `Namespace() собирает полные пути до каждого провала`
8. `Каталог по Namespace отдаёт уточнения, ответ 400 уходит клиенту`

### Карточки: дерево и его правила

- **рекурсия валидатора** — Поле-структуру валидатор проверяет сам, без тегов: спускается в поля и применяет их теги на любой глубине. Стоимость разбора дерева кэшируется по reflect.Type.
- **required на вложенном объекте** — На структуре-значении всегда проходит — она не бывает nil. «Объект обязателен» выражается *T с required: nil провалит проверку, разыменование после неё безопасно.
- **nil-указатель без required** — Пропускается целиком: внутрь не спускается, ошибки по полям не собираются. omitempty фиксирует это намерение явно и отличает договор от случайности.
- **structonly** — Поле-структура проверяется как единое целое, спуск в её поля не выполняется; отрабатывают только StructLevel-правила. validate:"-" выключает поле совсем.
- **Namespace()** — Полный путь от корня через точки: ProfileForm.Address.City. На деревьях — ключ каталога уточнений и материал для подсветки на фронте; после RegisterTagNameFunc — из json-имён.
- **срез структур** — Элементы среза валидатор сам не проверяет: нужен dive, после которого работает та же рекурсия, а ошибки получают индексы — Addresses[0].City.

Восьмой урок — [срезы и карты: тег dive](/courses/validator/lessons/08-srezy-i-dive/): правила до и после разделителя, unique, карты с keys и endkeys и цена поэлементной проверки. Когда вложенность растёт вместе с API, пригодится урок про [DTO-границы](/courses/validator/lessons/16-dto-granicy/) — там решаем, что проверять на входе, а что домену; а адрес, телефон и прочие персональные данные из этого урока вернутся с юридической стороны в [уроке про ПДн и 152-ФЗ](/courses/validator/lessons/22-pdn-152fz/). Код профиля с адресом соберите локально: песочница сайта исполняет только чистый Go, валидатор — программа вашего терминала, а интерактивы проверяют договоры nil против required без запуска.

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

- Вложенные структуры валидатор проверяет рекурсией без дополнительных тегов — ошибки собираются со всего дерева.
- Namespace() — полный путь через точки, Field() — последний сегмент; на деревьях каталог уточнений ключуют по Namespace().
- required на структуре-значении ничего не проверяет; «объект обязателен» — это *T с required.
- nil-указатель без required пропускается целиком; omitempty делает опциональность явным договором.
- structonly останавливает спуск в поля; validate:"-" выключает поле совсем — это разные задачи.
- RegisterTagNameFunc переименовывает каждый сегмент пути: Namespace() становится маршрутом json-имён для фронта.
- Для срезов структур перед рекурсией стоит dive; ошибки получают индексы вроде Addresses[0].City.

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

Возьмите форму заказа из урока и доведите до полного договора: Delivery *Address с omitempty, Invoice *Address с required, Positions []Item с dive для следующего урока. Прогоните локально четыре состояния: Delivery nil, Delivery пустым объектом, Invoice nil, Invoice заполненным наполовину. Для каждого запишите, какие записи легли в ValidationErrors и какие пути показал Namespace(). Потом замените Delivery на структуру-значение и объясните себе, почему исчезла возможность сказать «объекта может не быть».

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

### Как проверить «хотя бы один объект из среза заполнен правильно»?

Тегами уровня контейнера это не выражается: dive проверит каждый элемент, а правило «хотя бы один валиден» — это бизнес-условие. Путь — StructLevel-проверка на одиннадцатом уроке или своя функция-валидатор на десятом, которые читают весь срез и решают по своей логике. Держите её вне тегов, чтобы не имитировать перечисления строками.

### Валидатор зациклится, если структуры ссылаются друг на друга?

Циклы в структурах — признак сломанной модели данных, и валидатор не обязан их спасать: бесконечный спуск по ссылкам переполнит стек раньше, чем вернёт ответ. Разрывайте циклы указателями на корневые справочники или описывайте ссылку идентификатором, а не копией структуры — заодно упростите и сериализацию.

### Почему после omitempty на *Address заказ прошёл без адреса доставки?

Потому что так вы и написали: nil-указатель пропускается целиком, поля не проверяются. omitempty честен — «объекта может не быть». Если логика требует объект, ставьте required; если объект нужен только при некоторых условиях — условные теги девятого урока или отдельные структуры под каждый вариант формы.
