---
title: "Поля: типы и валидация"
description: "ent поля: типы field.String, field.Int, field.JSON, field.Enum, дефолты Default, Optional и Nillable, валидаторы MaxLen и Positive — развиваем схему микроблога."
course: "ent: графовая ORM с кодогенерацией"
url: "https://golangmaster.ru/courses/ent/lessons/03-polya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Поля: типы и валидация

*Курс «ent: графовая ORM с кодогенерацией», урок 3 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ent/lessons/03-polya/*

Таблица describe из прошлого урока обещала колонки Optional, Nillable и Default — пришло время расшифровать каждую. Тема урока — ent поля: каталог конструкторов field.*, дефолты через Default и DefaultFunc, разница Optional и Nillable и валидация схемы ent — MaxLen, Positive, Match. Дополним User возрастом, био, ролью и временем создания и закроем домашку: соберём post.go с лимитом на заголовок.

Во [втором уроке](/courses/ent/lessons/02-shemy/) мы остановились на таблице, которую печатает ent describe: у полей name и email там стояли колонки Optional, Nillable, Default — все с false, и мы пообещали расшифровать их по ходу курса. Час пришёл. Сегодня дополняем схему User под микроблог: возраст с проверкой на положительность, био, роль из фиксированного списка, время регистрации с дефолтом — и заодно закроем домашку со вторым файлом схемы. Разбираем ent поля по порядку: сначала каталог типов, потом правила дефолтов, затем валидаторы. Путь у всех конструкторов один: конструктор задаёт тип, цепочки методов после него — ограничения и поведение. Правило прошлого урока остаётся в силе: всё, что мы напишем, живёт в ent/schema, а Set-методы, валидаторы и новые типы сгенерирует go generate ./ent — руками их никто не набирает.

### Типы ent field: каталог конструкторов

Все конструкторы полей живут в пакете entgo.io/ent/schema/field — мы импортировали его ещё во втором уроке. Синтаксис единый: field.String("name") — первый аргумент становится именем колонки, методы после него — атрибуты поля. Числовые поля самые разветвлённые: field.Int даёт в структуре int, рядом есть Int8, Int16, Int32, Int64 и беззнаковые варианты, field.Float — это float64 (есть и Float32). Поддерживаются все числовые типы Go, так что узких мест здесь почти не бывает. Строки делятся на field.String под короткие значения и field.Text под длинные тексты — body поста просится именно туда. Дальше по списку: field.Bool, field.Time со структурой time.Time, field.Bytes для сырых байтов ([]byte).

Три конструктора устроены иначе: они принимают не имя типа, а пример значения. field.UUID("token", uuid.UUID{}) выводит Go-тип из переданного образца, field.JSON("prefs", Prefs{}) — из вашего собственного типа, field.Enum("role") вообще работает со списком допустимых строк. Тип поля определяет тип в сгенерированной структуре и тип колонки в базе; правила второй трансформации — тема урока про миграции, пока следим за Go-стороной.

И имя поля превращается в имя колонки как есть — мы писали created_at с подчёркиванием именно поэтому. Если колонка должна называться иначе, есть StorageKey: field.String("name").StorageKey("user_name") оставит в коде поле Name, а в базе появится колонка user_name. Развязка нужна редко — обычно против легаси-схем, которые нельзя переименовывать, но знать про неё стоит до того, как колонки разъедутся по проекту.

### field.* → тип в Go и пример в микроблоге

| Конструктор | Тип в Go | Пример в микроблоге |
| --- | --- | --- |
| field.String("name") | string | имя пользователя |
| field.Text("body") | string | текст поста — длинный |
| field.Int("age") | int | возраст |
| field.Float("rating") | float64 | средняя оценка поста |
| field.Bool("verified") | bool | email подтверждён |
| field.Time("created_at") | time.Time | время регистрации |
| field.UUID("token", uuid.UUID{}) | uuid.UUID | API-токен пользователя |
| field.JSON("prefs", Prefs{}) | Prefs (ваш тип) | настройки интерфейса |
| field.Enum("role") | string | роль: user, moderator, admin |
| field.Bytes("avatar") | []byte | аватарка блобом |

### Фрагменты Fields(): остальные типы на витрине

```go
import "github.com/google/uuid"

// Тип для JSON-поля: обычная Go-структура рядом со схемой.
type Prefs struct {
        Theme      string `json:"theme"`      // тема интерфейса
        Newsletter bool   `json:"newsletter"` // согласие на рассылку
}

// ...и фрагменты Fields() разных сущностей микроблога:
field.Float("rating").
        Positive(),             // средняя оценка поста: строго больше нуля
field.Bool("verified").
        Default(false),         // email подтверждён?
field.UUID("token", uuid.UUID{}).
        Default(uuid.New),      // API-токен: новый при каждом создании
field.JSON("prefs", Prefs{}).
        Optional(),             // настройки интерфейса целиком
```

Читаем сверху вниз. Prefs — обычная Go-структура с json-тегами: ent сериализует её значение в JSON и положит в текстовую/JSON-колонку, а из кода вы работаете с обычной структурой через SetPrefs(Prefs{...}). rating — поле для будущего рейтинга постов: float64 с проверкой на положительность. У verified дефолт false — необязательное значение с честным значением по умолчанию. token: второй аргумент field.UUID — образец значения, по нему ent выводит тип uuid.UUID из пакета github.com/google/uuid; Default(uuid.New) — снова функция: новый токен на каждое создание. prefs собран в JSON целиком — флаги настроек удобно хранить одним полем, пока их меньше десятка.

### Default: значение, которое ставится само

Поля с Default не требуют Set-вызова: при создании ent сам подставит значение. Для констант это выглядит скучно и надёжно: field.Bool("verified").Default(false), field.Enum("role") с Default("user"). Интересное начинается со времени. У time-полей Default принимает функцию: field.Time("created_at").Default(time.Now) — time.Now без скобок, вы передаёте саму функцию, а ent вызывает её в момент создания каждой записи. Классическая ошибка — time.Now() со скобками: выражение вычислится один раз на старте, и все записи за жизнь процесса получат одно и то же время. Ошибка коварная: код собирается, тесты на свежем клиенте проходят, а через неделю в базе обнаруживается поле created_at, застывшее в моменте.

Для любых типов с генерацией значения есть DefaultFunc — туда передают свою функцию-генератор, например cuid.New. Поле с Default остаётся обязательным по смыслу: записи без значения не будет, просто заполняет его ent, а не вы. Ограничение: уникальным полям Default запрещён — авторы ent сознательно не смешивают автозначения с уникальностью.

Рядом живут два атрибута из таблицы describe. UpdateDefault — функция, применяемая при каждом обновлении: поле updated_at описывают парой Default(time.Now).UpdateDefault(time.Now). Immutable — поле, которое задаётся только при создании: для update-билдеров сеттер просто не генерируется, поменять created_at через код не выйдет. Вернём оба атрибута в схему чуть ниже.

### Время создания и время правки

```go
field.Time("created_at").
        Default(time.Now).        // функция вызывается при каждом создании
        Immutable(),              // после создания поле не меняется

field.Time("updated_at").
        Default(time.Now).
        UpdateDefault(time.Now),  // обновляется при каждом Update
```

Пара штампов, которые вы будете копировать из проекта в проект. created_at пишется один раз: Default(time.Now) ставит время при создании, Immutable() запрещает его менять — сеттера в update-билдере не появится. updated_at живёт активнее: UpdateDefault(time.Now) подставляет свежее время при каждом обновлении сущности, так что поле честно отражает последнюю правку. Никаких хуков и ручных присваиваний — всё это ent делает в сгенерированном коде до похода в базу. В нашу схему User мы возьмём только created_at: updated_at пригодится, когда посты начнут редактироваться.

### Проверка: когда срабатывает Default(time.Now)

**Вопрос:** В схеме написано field.Time("created_at").Default(time.Now). Когда вычисляется время для конкретной записи?

- Один раз при go generate: время зафиксируется в сгенерированном коде
- ✅ При каждом создании записи: ent вызывает time.Now в момент Save
- Одно и то же для всех записей: время запуска приложения
- Время считает база: ent записывает в схему CURRENT_TIMESTAMP

time.Now передаётся без скобок — это функция, и сгенерированный код вызывает её при создании каждой записи, до похода в базу. Вариант со скобками, time.Now(), дал бы застывшее значение: выражение вычислилось бы один раз, и все записи получили бы одно время. Дефолт на стороне базы — отдельная опция entsql.Default, по умолчанию ent хранит дефолты у себя в сгенерированном коде.

### Optional и Nillable ent: два уровня пустоты

Поля ent обязательны по умолчанию: не вызвали SetAge — Save вернёт ошибку вида ent: missing required field "User.age". ent не подставляет нулевые значения молча, и это спасает от половины тихих багов. Метод Optional() переводит поле в другой режим: его можно не задавать при создании, а колонка в базе становится NULL-able. Биография пользователя — классический кандидат: есть она не у всех.

Но у Optional есть особенность, о которой редко думают заранее: в сгенерированной структуре поле остаётся обычным string. Пропустили SetBio — в Bio пустая строка, неотличимая от намеренно пустой биографии «я ничего о себе не рассказываю». Нулевое значение Go и отсутствие значения в базе слиплись в одно — знакомая болезнь, если вы привыкли к GORM: [в его вводном уроке](/courses/gorm/) мы видели, как нулевые значения и «нет данных» путаются в одном поле структуры.

Разводит их Nillable(). Для Optional-поля типа T он меняет тип в структуре на *T: ent читает NULL из базы и кладёт в поле nil. Получилась классическая пара Optional().Nillable(): SetBio можно не вызывать, а в коде Bio — *string, где nil значит «в базе NULL». Nillable работает и на обязательных полях — чтобы не ловить нулевые значения при частичных выборках, — но в прикладных схемах почти всегда встречается в этой паре. Предикаты для проверки на NULL и остальная NULL-семантика — тема четвёртого урока, здесь берём минимум, который нужен для схемы.

### Что напечатает Bio без SetBio

```go
// bio в схеме: field.String("bio").Optional().Nillable()

u, err := client.User.
        Create().
        SetName("Анна").
        SetEmail("anna@example.com").
        SetAge(19).
        Save(ctx)

fmt.Println(u.Bio == nil)
```

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

- ✅ true: Bio — *string, поле не задавали, оно nil
- false: Bio — пустая строка, сравнение с nil всегда ложно
- Паника: нельзя читать неинициализированное поле структуры
- Ошибка Save: bio — обязательное поле, нужен SetBio

Optional разрешает не задавать поле — Save проходит, «missing required» не случается. Nillable делает поле структуры указателем: Bio *string, а ent читает NULL из базы как nil. Без Nillable поле было бы string с пустой строкой, и «не рассказали о себе» нельзя было бы отличить от «рассказали пустоту». Паники нет: чтение nil-указателя законно, паникует только разыменование.

### Валидация схемы ent: встроенные валидаторы

Валидатор — метод на билдере поля, который добавляет проверку значения. NotEmpty и Unique вы уже видели в [первом уроке](/courses/ent/lessons/01-znakomstvo/) — они появились в схеме User в первые минуты знакомства с ent. Теперь соберём каталог целиком. Для строк: MinLen и MaxLen — границы длины, Match — совпадение с регулярным выражением, NotEmpty — запрет пустой строки, который внутри устроен просто как MinLen(1) (пробел, кстати, пройдёт). Для чисел: Min и Max с включёнными границами, Range(i, j) для отрезка, Positive, Negative и NonNegative как готовые комбинации — Positive устроен как Min(1), поэтому ноль не пройдёт, и это надо помнить, описывая «необязательный» возраст. Валидаторов на одном поле может быть несколько — ent применит их все по очереди.

У MaxLen есть скрытая вторая работа: она же подсказывает генератору размер строковой колонки. А если встроенных проверок мало, любой билдер принимает Validate(fn) — вашу функцию, возвращающую nil или ошибку.

Рядом с валидаторами по смыслу живёт Sensitive(). Это не проверка, а пометка: поле вроде пароля не печатается в логах и отладочных выводах ent и пропускается при кодировании в JSON. struct-тегов у sensitive-полей не бывает — ent убирает их из сгенерированного представления сознательно.

### Каталог встроенных валидаторов

| Валидатор | Где применим | Что проверяет |
| --- | --- | --- |
| NotEmpty | string, []byte | не пустая строка; по сути MinLen(1) — одиночный пробел пройдёт |
| MinLen(i), MaxLen(i) | string, []byte | длина в байтах, границы включены; для счёта букв есть MinRuneLen и MaxRuneLen |
| Match(re) | string | значение совпадает с regexp — формат email, телефон, префикс |
| Min(i), Max(i) | числовые | не меньше i и не больше i — обе границы включены |
| Range(i, j) | числовые | значение в отрезке [i, j] |
| Positive | числовые | не меньше 1 (это Min(1)): ноль и минус не пройдут |
| Negative, NonNegative | числовые | не больше -1; не меньше нуля |
| Validate(fn) | любые поля | ваша функция func(T) error: nil — проверка пройдена |
| Unique | любые поля | не валидатор, а уникальный индекс в базе; с Default не совмещается |

### User после урока: поля с характером

```go
package schema

import (
        "time"

        "entgo.io/ent"
        "entgo.io/ent/schema/field"
)

// User — пользователь микроблога: пишет посты и комментарии.
type User struct {
        ent.Schema
}

// Fields — колонки таблицы users.
func (User) Fields() []ent.Field {
        return []ent.Field{
                field.String("name").
                        NotEmpty(),   // из урока 02: безымянных не даём
                field.String("email").
                        Unique(),
                field.Int("age").
                        Positive(),   // возраст: строго больше нуля
                field.String("bio").
                        MaxLen(280).  // лимит профиля
                        Optional().   // SetBio можно не вызывать
                        Nillable(),   // в структуре Bio *string
                field.Enum("role").
                        Values("user", "moderator", "admin").
                        Default("user"),
                field.Time("created_at").
                        Default(time.Now).
                        Immutable(),  // ставится один раз при создании
        }
}

// Edges — связи появятся в уроках 05 и 06.
func (User) Edges() []ent.Edge {
        return nil
}
```

Схема выросла с двух полей до шести, и каждое добавлено по сценарию урока. age: Positive отвергает ноль и минус — поле обязательное, SetAge нужен при каждом создании. bio: MaxLen(280) плюс пара Optional и Nillable — колонка NULL-able, в структуре Bio *string. role: enum из трёх значений с дефолтом user. created_at: время ставит сам ent при создании, Immutable не даёт его переписать. name и email не изменились — они уже были собраны во втором уроке. Порядок методов в цепочке свободный: Optional().Nillable() и Nillable().Optional() дают одно и то же. После правки не забудьте go generate ./ent — только тогда появятся SetAge, SetBio и SetRole.

### Проверка: что даёт Nillable

**Вопрос:** field.String("bio").Optional().Nillable() — как поле Bio выглядит в сгенерированной структуре ent/user.go?

- Bio string — обычное поле с нулевым значением
- ✅ Bio *string — указатель: NULL из базы читается как nil
- Bio sql.NullString — обёртка из стандартной библиотеки
- Поля Bio в структуре не будет — Optional убирает поле

Nillable меняет Go-тип поля на *T — в этом его главное отличие от одного лишь Optional, который влияет только на правила создания и колонку. sql.NullString — подход database/sql из курса sqlc, ent использует указатели. Поле остаётся в структуре: Optional влияет на обязательность при создании, а не на сгенерированный код.

### Enum: роль из фиксированного списка

field.Enum("role").Values("user", "moderator", "admin") — Values задаёт полный список допустимых значений, и это список навсегда: добавить четвёртую роль значит отредактировать схему и перегенерировать код, других путей нет. Генератор отработает щедро: в пакете ent появится тип Role — строка с репутацией — и константы RoleUser, RoleModerator, RoleAdmin, так что в прикладном коде роли живут именованными значениями, а не сырыми строками.

Честно про границу защиты. Компилятор от произвольной строки не спасёт: SetRole("boss") соберётся, потому что нетипизированная строковая константа присваивается любому строковому типу. Но дальше в дело вступает сгенерированный валидатор: Save сверит значение со списком Values и вернёт ошибку вида «invalid enum value» ещё до базы. Свой контур защиты у enum замкнут, и это приятная новость.

Default("user") заполняет роль, если её не задали: новые пользователи по умолчанию обычные, а модератора назначает отдельная операция обновления. По умолчанию в PostgreSQL и SQLite enum — обычная строковая колонка, в MySQL колонка становится нативным ENUM; если хочется нативных enum-типов и в PostgreSQL, у ent есть отдельный сценарий миграций — вернёмся к нему в уроке про миграции.

### post.go: домашка второго урока закрыта

```go
package schema

import (
        "entgo.io/ent"
        "entgo.io/ent/schema/field"
)

// Post — пост микроблога.
type Post struct {
        ent.Schema
}

// Fields — колонки таблицы posts.
func (Post) Fields() []ent.Field {
        return []ent.Field{
                field.String("title").
                        NotEmpty().   // пост без заголовка не создаём
                        MaxLen(140),  // лимит как у старого твита
                field.Text("body"),
        }
}

// Edges — связи появятся в уроках 05 и 06.
func (Post) Edges() []ent.Edge {
        return nil
}
```

Каркас post.go создал ent init ещё во втором уроке, домашка просила заполнить его полями title и body — вот заполненная версия. title: NotEmpty() не даёт анонимных постов, MaxLen(140) ставит верхнюю границу и заодно подсказывает генератору размер колонки. body собран на field.Text: длинный текст без лимита длины — если ограничение понадобится, MaxLen сработает и здесь. Оба поля обязательные: пост без заголовка или тела не пройдёт Save. Ребро на автора намеренно не трогаем — edges займут пятый и шестой уроки, и тогда title и body получат соседа author.

### Честно про валидацию: что возвращает Save

Самое важное про валидацию — где она выполняется. Валидаторы живут в сгенерированном коде: Save сначала прогоняет все проверки и только потом идёт в базу. Провал валидатора выглядит так: Save(ctx) возвращает не nil-ошибку, а значение типа ValidationError из пакета blog/ent, и текст у неё конкретный: ent: validator failed for field "User.age": value out of range. Обязательное поле, которое забыли задать, — тоже ValidationError, но с другим текстом: ent: missing required field "User.age". Обе ошибки типизированы, различаются через errors.As, у ValidationError есть поле Name с именем провинившегося поля — удобно логировать.

Уникальность живёт отдельно: Unique() создаёт уникальный индекс, и проверяет его сама база. Дубликат email рождает ошибку от СУБД, которую ent заворачивает в ConstraintError — она прилетает после похода в базу, а не до. Это не причуда, а честное распределение ролей: валидатор не увидит параллельную вставку из другого процесса, индекс увидит.

Теперь границы честности. Валидаторы исполняются в клиенте ent — всё, что обходит клиент, мимо проверок: рукописный INSERT, второй сервис на той же базе, скрипт миграции данных. Поэтому правила, от которых зависит целостность, дублируют ограничениями базы: NOT NULL, уникальные индексы, CHECK. Валидаторы — это ранний отказ в вашем коде с понятным текстом ошибки, а не бронированная дверь в данные.

### Проверка: ноль против Positive

**Вопрос:** В схеме field.Int("age").Positive(). Что вернёт client.User.Create().SetAge(0).Save(ctx)?

- Создастся пользователь с age = 0 — ноль валидатор не трогает
- ✅ ValidationError: валидатор не пропустил значение ещё до похода в базу
- ConstraintError: база отклонила вставку из-за нуля
- Пользователь создастся, а ноль превратится в NULL

Positive устроен как Min(1): значение должно быть не меньше единицы, ноль не проходит. Проверка выполняется в сгенерированном коде на этапе Save — до любого обращения к базе, поэтому и ошибка типа ValidationError с текстом validator failed, а не ошибка СУБД. ConstraintError здесь не при чём: база не увидела этот запрос вовсе.

> **MaxLen считает байты, а не буквы.** Внутри MaxLen стоит len(v), а len в Go — это байты. Кириллица занимает два байта на букву, поэтому MaxLen(280) у bio пропустит примерно 140 русских заголовков-букв — вдвое меньше, чем подсказывает интуиция. Если лимит должен считаться в буквах, есть парные MinRuneLen и MaxRuneLen — они считают руны. На латинице разницы нет: для email и ников MaxLen честен.

### Жизненный цикл поля с валидатором

Расставьте шаги от описания поля в схеме до ловли двух разных ошибок.

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

1. `описать поле в Fields(): field.String("bio").MaxLen(280).Optional()`
2. `go generate ./ent — появились SetBio и валидатор длины`
3. `SetBio("пишу о Go") — заполнить поле при создании`
4. `Save(ctx) — проверки прогоняются до похода в базу`
5. `валидатор не пустил? Save вернул ValidationError`
6. `ошибка уникальности приходит от базы — это ConstraintError`

### Восстановите фрагмент схемы

Впишите числовой конструктор, метод списка значений enum и функцию времени.

```go
field.___("age").
        Positive(),
field.Enum("role").
        ___("user", "moderator", "admin").
        Default("user"),
field.Time("created_at").
        Default(time.___)
```

Подсказка: Числовое поле — field.Int, список допустимых значений enum задаёт Values, а time.Now подставляют как функцию — без скобок.

Правильные ответы в пропусках: `Int`, `Values`, `Now`

### Карточки: поля, дефолты, валидаторы

- **field.*** — Конструкторы полей из entgo.io/ent/schema/field: String, Text, Int (и другие числовые), Float, Bool, Time, Bytes, UUID, JSON, Enum. Первый аргумент — имя колонки, цепочки методов — ограничения. Для UUID и JSON второй аргумент — образец значения, по которому выводится Go-тип.
- **Default и DefaultFunc** — Значение по умолчанию: Default(false) — константа, Default(time.Now) и DefaultFunc(fn) — функция, вызываемая при каждом создании. UpdateDefault применяется при обновлениях, Immutable запрещает менять поле после создания. Уникальным полям Default запрещён.
- **Optional** — Поле можно не задавать при создании, колонка становится NULL-able. Без Optional пропущенное поле вернёт ошибку missing required field. В структуре поле остаётся обычным T — для nil-семантики нужен Nillable.
- **Nillable** — Меняет тип поля в сгенерированной структуре на *T: NULL из базы читается как nil, а не как нулевое значение. Классическая пара — Optional().Nillable(); работает и на обязательных полях для частичных выборок.
- **Positive, Min, Max** — Числовые валидаторы с включёнными границами: Min(1) пропускает единицу, Max(130) пропускает 130. Positive — это Min(1), ноль не пройдёт; Negative — Max(-1), NonNegative — Min(0). Выполняются в Save до базы, ошибка — ValidationError.
- **Sensitive** — Пометка строкового поля вроде пароля: значение не печатается в логах и отладочных выводах и пропускается при кодировании в JSON. Struct-тегов у sensitive-полей не бывает.

### Что дальше: NULL-семантика и рёбра

Схема User повзрослела, пост получил свои поля — фундамент для связей готов. Четвёртый урок полностью посвящит паре Optional и Nillable: предикаты для проверки на NULL, поведение Set-методов и то, как NULL ведёт себя в чтении и загрузках. Пятый и шестой — рёбра: наконец свяжем User и Post, добавим комментарии, и поля author_id появятся в таблице posts сами. Напоминание из второго урока остаётся в силе: после каждой правки схемы — go generate ./ent, иначе новые Set-методы не скомпилируются. Полный план из 25 уроков лежит на [странице курса ent](/courses/ent/), а домашка ниже — лучший способ закрепить сегодня описанные поля.

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

- Поля ent строятся конструкторами field.*: String, Text, Int, Float, Bool, Time, Bytes, UUID, JSON, Enum; тип в Go определяется конструктором или образцом значения.
- Default подставляет значение при создании: Default(time.Now) — функция без скобок, вызывается на каждый Save; UpdateDefault работает при обновлениях; уникальным полям дефолт запрещён.
- Optional разрешает не задавать поле при создании, колонка становится NULL-able; без него Save вернёт missing required field.
- Nillable меняет поле в структуре на *T: NULL читается как nil; пара Optional().Nillable() разводит «нет данных» и нулевое значение.
- Валидаторы (NotEmpty, MaxLen, MinLen, Match, Min, Max, Positive, Range, Validate) выполняются в сгенерированном коде на Save, до похода в базу; ошибка — ValidationError.
- Уникальность проверяет база: нарушение индекса приходит уже от СУБД в виде ConstraintError.
- MaxLen считает байты: 280 байт кириллицы — примерно 140 букв; для счёта по буквам есть MaxRuneLen и MinRuneLen.

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

Доведите свою схему микроблога до версии урока: в user.go добавьте age с Positive, bio с MaxLen(280).Optional().Nillable(), role с Values и Default("user"), created_at с Default(time.Now).Immutable(); в post.go соберите title (NotEmpty + MaxLen(140)) и body на field.Text. Запустите go generate ./ent, затем в main.go попробуйте создать пользователя с SetAge(0) и прочитайте текст ошибки ValidationError. Повторите создание с SetAge(19), не задавая bio, напечатайте u.Bio и убедитесь, что это nil. Вопрос на подумать: у email нет ни одного валидатора — что всё-таки защищает его от дубликатов и в какой момент это срабатывает?

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

### Валидаторы ent защищают саму базу от мусора?

Нет. Валидаторы исполняются в сгенерированном клиенте на этапе Save: любой обход ent — рукописный INSERT, другой сервис, скрипт заливки данных — пройдёт мимо проверок. Целостность на уровне базы обеспечивают ограничения СУБД: NOT NULL, уникальные индексы, CHECK. Валидаторы дают ранний отказ с понятным текстом в вашем коде.

### Чем Default отличается от Optional?

Default подставляет значение при создании, если вы не вызвали Set: записи без значения не будет, но и Set-вызов не нужен. Optional делает поле необязательным: значения может не быть вовсе, колонка — NULL-able. Поле с Default остаётся заполненным, поле с Optional может быть пустым — это разные контракты.

### Можно ли добавить свой валидатор с собственной ошибкой?

Да. Любой билдер поля принимает Validate(fn), где fn — функция func(T) error: верните nil, если значение годится, или свою ошибку иначе. Свой валидатор работает вместе со встроенными — ent применит их все при Save и вернёт ValidationError с вашим текстом.
