В Go нет try/catch. Ошибка — это обычное значение, которое функция возвращает наравне с результатом, а вызывающий по идиоме должен её обработать — или явно проигнорировать через _. Компилятор не заставляет проверять error (это не «неиспользованная переменная»), но и молча спрятать его за невидимым исключением нельзя: отказ от обработки виден в тексте как осознанный _ = .... Отсюда знаменитое if err != nil, которое одни считают ритуалом, а другие — главной честностью языка: поток ошибок проходит прямо через код, а не мимо него. Эта статья — про механику: как ошибки оборачивают через %w, как errors.Is/errors.As разбирают цепочку, что даёт errors.Join, когда уместны panic/recover — и почему error, внутри которого лежит nil, всё-таки не равен nil.
Это вторая статья мини-серии «Основы Go вглубь». Про сравнение подхода Go с исключениями Java/Python/Rust — отдельный разбор «Обработка ошибок в разных языках»; здесь остаёмся внутри Go и разбираем стандартную библиотеку до дна. Все примеры взяты из живого стенда — ссылка в конце.
В статье
- Ошибки — значения, не исключения
- Sentinel-ошибки и обёртывание
- errors.Is и errors.As
- errors.Join — несколько ошибок сразу
- Типизированные ошибки
- Ловушка «типизированный nil в error != nil»
- panic, recover и границы применимости
- Демо и версии
- Документация и первоисточники
Ошибки — значения, не исключения
error — это интерфейс из одного метода:
type error interface {
Error() string
}Любой тип, у которого есть метод Error() string, является ошибкой. Функции возвращают error последним значением, а вызывающий проверяет его сразу же:
f, err := os.Open("config.yaml")
if err != nil {
return fmt.Errorf("открыть конфиг: %w", err)
}
defer f.Close()
// ... f гарантированно валиден: до сюда доходим только когда err == nilФилософия за этим — явный поток ошибок. В языках с исключениями сбой распространяется невидимо: любой вызов вправе «выстрелить» через throw, и понять, какие строки могут прервать выполнение, по тексту функции нельзя. В Go сбой возвращается как значение, и место, где он игнорируется, видно глазами — переменную err придётся либо проверить, либо демонстративно присвоить _. Цена — многословность (if err != nil повторяется часто); выгода — на каждом вызове видно, что может пойти не так и кто это обрабатывает. Как этот компромисс выглядит на фоне try/catch Java, Result<T, E> Rust и исключений Python — в статье «Обработка ошибок в разных языках».
Раз ошибка — обычное значение, её можно сохранить в переменную, сравнить, передать, объединить с другой и завернуть в контекст. Именно эти операции и разбираем дальше.
Sentinel-ошибки и обёртывание
Sentinel-ошибка — заранее объявленное экспортируемое значение, с которым вызывающий сравнивает результат, не разбирая текст сообщения:
// ErrNotFound — заранее объявленное значение; вызывающий проверяет
// именно этот случай, не завися от текста сообщения.
var ErrNotFound = errors.New("запись не найдена")Классика стандартной библиотеки — io.EOF, sql.ErrNoRows. Проблема наивного подхода в том, что sentinel хочется вернуть с добавленным контекстом («при каком id не нашли?»), а голое errors.New контекста не несёт. Решение — обёртывание: fmt.Errorf с глаголом %w создаёт новую ошибку, у которой снаружи своё сообщение, а внутри сохранён исходный sentinel:
// LookupUser для неизвестного id возвращает ErrNotFound, обёрнутую
// в контекст через %w: обёртка добавляет сообщение, но сохраняет
// sentinel внутри цепочки.
func LookupUser(id string) error {
if id == "" {
return fmt.Errorf("поиск пользователя %q: %w", id, ErrNotFound)
}
return nil
}Разница между %w и %v принципиальна. %v (или %s) вставляет текст ошибки в новое сообщение — исходная ошибка теряется, остаётся только строка. %w (wrap) сохраняет саму ошибку как звено цепочки: снаружи LookupUser("") вернёт поиск пользователя "": запись не найдена, но внутри по-прежнему живёт ErrNotFound, и его можно достать программно. Обёртки вкладываются друг в друга сколько угодно: каждый слой обработки добавляет свой контекст через %w, формируя цепочку от самой внешней ошибки к исходной. Как её разбирают — в следующем разделе.
Технически %w работает так: fmt.Errorf возвращает значение, у которого есть метод Unwrap() error, отдающий обёрнутую ошибку. На этом методе и строится весь механизм errors.Is/errors.As.
errors.Is и errors.As
Раз ошибка спрятана под обёрткой, прямое сравнение == перестаёт работать:
err := LookupUser("") // обёрнутый ErrNotFound
err == ErrNotFound // false! err — это обёртка, а не сам sentinelerr здесь — значение, возвращённое fmt.Errorf, а не ErrNotFound. Сравнение по == смотрит на верхний слой и видит обёртку, а не то, что внутри. Стоит кому-то добавить ещё один %w-слой — и код, полагавшийся на err == ErrNotFound, тихо ломается. Поэтому сравнивать нужно не ==, а errors.Is, который разворачивает всю цепочку (вызывая Unwrap слой за слоем) и ищет совпадение на каждом уровне:
func TestIsFindsSentinelThroughChain(t *testing.T) {
err := LookupUser("") // вернёт обёрнутый ErrNotFound
// Прямое == не сработало бы: err — обёртка, не сам sentinel.
if err == ErrNotFound {
t.Fatal("обёртка не должна быть равна sentinel по ==")
}
// errors.Is разворачивает цепочку и находит ErrNotFound.
if !errors.Is(err, ErrNotFound) {
t.Errorf("errors.Is не нашёл ErrNotFound в %v", err)
}
}Тест из стенда доказывает и то, что глубина обёртки не важна — Is дойдёт до sentinel через сколько угодно слоёв:
func TestIsThroughDoubleWrap(t *testing.T) {
base := LookupUser("") // слой 1
wrapped := fmt.Errorf("слой обработки: %w", base) // слой 2
if !errors.Is(wrapped, ErrNotFound) {
t.Errorf("Is не нашёл ErrNotFound сквозь двойную обёртку: %v", wrapped)
}
}errors.As решает другую задачу: не «эта ли конкретная ошибка внутри?», а «есть ли в цепочке ошибка такого типа, и если да — дай её мне». Он проходит цепочку, находит первое звено, присваиваемое целевому типу, и записывает его в переданный указатель — после чего доступны все поля типизированной ошибки:
func TestAsExtractsTypedError(t *testing.T) {
err := ValidateUser("") // вернёт обёрнутый *ValidationError
var verr *ValidationError
if !errors.As(err, &verr) {
t.Fatalf("errors.As не извлёк *ValidationError из %v", err)
}
if verr.Field != "name" { // поля доступны после извлечения
t.Errorf("Field = %q, ожидали \"name\"", verr.Field)
}
}Мнемоника: Is — сравнение с известным значением (sentinel), As — извлечение по типу (типизированная ошибка, чтобы прочитать её поля). Второй аргумент As — обязательно указатель на переменную нужного типа (&verr), иначе будет паника на этапе выполнения.
errors.Join — несколько ошибок сразу
Иногда сбой не один: форма не прошла валидацию по трём полям, батч упал на нескольких элементах. До Go 1.20 приходилось склеивать сообщения строками, теряя возможность проверить каждую причину. errors.Join (Go 1.20) объединяет несколько ошибок в одну, сохраняя все как разворачиваемые звенья — так что errors.Is и errors.As находят любую из них:
// RegisterUser выполняет обе проверки и объединяет результаты через
// errors.Join: в возвращённой ошибке живут ОБЕ причины.
func RegisterUser(id, name string) error {
// errors.Join(nil, nil) == nil, поэтому при успехе — чистый nil.
return errors.Join(LookupUser(id), ValidateUser(name))
}Две важные детали. Во-первых, errors.Join игнорирует nil-аргументы, а если все они nil — возвращает nil. Поэтому RegisterUser при успехе обеих проверок отдаёт настоящий nil, а не «пустую непустую» ошибку. Во-вторых, из объединённой ошибки достаются оба вложенных типа сразу:
func TestJoinFindsAnyError(t *testing.T) {
err := RegisterUser("", "") // обе проверки провалятся
if !errors.Is(err, ErrNotFound) { // нашёл sentinel из первой
t.Errorf("Is не нашёл ErrNotFound в объединённой ошибке")
}
var verr *ValidationError
if !errors.As(err, &verr) { // и типизированную из второй
t.Errorf("As не нашёл *ValidationError в объединённой ошибке")
}
}Тот же Go 1.20 разрешил и несколько %w в одном fmt.Errorf (fmt.Errorf("...: %w и %w", e1, e2)) — под капотом это тоже даёт мультиобёртку, по которой Is/As обходят все ветви.
Типизированные ошибки
Sentinel отвечает на вопрос «да/нет: этот ли случай?». Когда же вызывающему нужны структурированные детали сбоя — какое поле, какое значение, какой код, — заводят собственный тип, реализующий error:
// ValidationError несёт структурированные детали, которые вызывающий
// достаёт через errors.As.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("поле %q: %s", e.Field, e.Reason)
}Дальше её возвращают, при необходимости обернув контекстом, а вызывающий достаёт через errors.As (см. выше) и читает поля.
Когда что выбирать:
- Sentinel — когда важен только факт: «не найдено», «нет прав», «истёк дедлайн». Вызывающему нечего прочитать сверх самого факта, вся логика — в ветке
if errors.Is(...). Дёшево, минимум кода. - Типизированная ошибка — когда вызывающему нужны данные для реакции: какое поле не прошло валидацию, сколько осталось попыток, какой HTTP-статус вернуть. Всё, что должно уехать наверх вместе с фактом сбоя, кладётся в поля структуры.
Практическое замечание про приёмник: метод Error() объявляют на указателе (*ValidationError), а не на значении, и errors.As тогда ищет *ValidationError. Это устоявшаяся идиома — она же ведёт нас прямиком к главной практической ловушке.
Ловушка «типизированный nil в error != nil»
Самая коварная ошибка вокруг error — та, где if err != nil срабатывает, хотя фактической ошибки нет. Корень — в том, что error это интерфейс, а интерфейсное значение хранит пару (тип, значение). Интерфейс равен nil только когда пусты оба компонента. Если положить в error nil-указатель конкретного типа, компонент «тип» окажется непустым (*MyError) — и интерфейс не будет равен nil, несмотря на nil внутри.
Стенд показывает это буквально (сверено на go1.26.3):
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
// makeBad — ЛОВУШКА: nil-указатель *MyError, возвращённый как error.
// Интерфейс получает пару (тип=*MyError, значение=nil) — и это НЕ nil.
func makeBad() error {
var p *MyError = nil // ошибки нет — указатель nil
return p // но тип пары = *MyError, значение = nil
}
// makeGood — как правильно: буквальный nil типа error.
func makeGood() error {
return nil // пара (тип=nil, значение=nil) — настоящий nil-интерфейс
}Запуск из корня модуля go-fundamentals — go run ./errors/cmd/typed-nil (или go run ./cmd/typed-nil из папки errors/) — печатает:
badErr != nil -> true (внутри указатель nil, но тип пары = *MyError)
goodErr != nil -> falsebadErr != nil даёт true — и ветка обработки ошибки срабатывает на ровном месте. goodErr, где вернули явный nil типа error, ведёт себя правильно. Механику интерфейсной пары (тип, значение) подробно разбирает сиблинг «Интерфейсы в Go» — здесь важен вывод, а не устройство.
У ловушки есть и второе дно. Error() объявлен на указателе (func (e *MyError) Error() string), поэтому прямой вызов метода на таком typed-nil — например, когда собирают сообщение "ошибка: " + err.Error() — обращается к полю через nil-получатель и паникует с runtime error: invalid memory address or nil pointer dereference. Важная тонкость: fmt.Println(err), %v, log.Println(err) от этого защищены — стандартный вывод для nil-указателя печатает <nil>, не вызывая метод; падает именно собственный код, дёргающий Error() напрямую. То есть typed-nil не только заводит в ложную ветку err != nil, но и способен уронить код на попытке достать текст ошибки вручную.
Откуда это берётся в реальном коде: функция объявляет типизированную переменную ошибки, что-то в неё пишет по ветке, а в конце возвращает — и по «успешной» ветке отдаёт nil-указатель как error.
func do() error {
var e *MyError // nil-указатель конкретного типа
if somethingFailed() {
e = &MyError{msg: "..."}
}
return e // ПЛОХО: даже при успехе вернётся не-nil error
}Правило: возвращайте error как nil явно, а не типизированную nil-переменную. Объявляйте возвращаемое значение как error (не как *MyError), а по успешной ветке возвращайте буквальный return nil. Если без типизированной переменной не обойтись — проверяйте её на nil перед возвратом и возвращайте nil явно:
func do() error {
if somethingFailed() {
return &MyError{msg: "..."} // ошибка — возвращаем конкретный тип
}
return nil // успех — буквальный nil типа error
}panic, recover и границы применимости
panic разворачивает стек, выполняя отложенные defer, и роняет программу, если её не перехватили. recover, вызванный напрямую из отложенной функции, останавливает разворачивание и возвращает значение, переданное в panic. Это выглядит как try/catch — но применять их вместо возврата ошибок в Go не принято.
Когда panic уместен:
- Действительно исключительные, невосстановимые ситуации — нарушение инварианта, до которого при корректном коде дойти невозможно («такого не должно случиться никогда»).
- Инициализация, где сбой означает, что программа не имеет права запускаться:
regexp.MustCompile,template.Must— паникуют на битой константе, потому что чинить нечего, надо падать сразу при старте. - Граница горутины/пакета: внутри может паниковать сторонний код, а наружу принято отдавать аккуратный
error— тогда панику перехватывают вdeferи конвертируют.
Когда panic неуместен: для обычных, ожидаемых сбоев — «файл не найден», «валидация не прошла», «сеть недоступна». Это штатные ветки исполнения, их место — возвращаемое значение error, а не разворачивание стека.
Стенд демонстрирует именно приём с границы пакета — перехват паники и превращение её в error через именованный возвращаемый параметр:
// safeDivide: defer с recover перехватывает панику и превращает её
// в error через именованный возвращаемый параметр err.
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("восстановлено из паники: %v", r)
}
}()
if b == 0 {
panic(errors.New("деление на ноль"))
}
return a / b, nil
}Запуск из корня модуля go-fundamentals — go run ./errors/cmd/panic-recover (или go run ./cmd/panic-recover из папки errors/) — печатает:
safeDivide(10, 2) = 5, err = <nil>
safeDivide(10, 0) = 0, err = восстановлено из паники: деление на ноль
дошли до конца main — процесс не упалПаника перехвачена, превращена в обычную ошибку, процесс дошёл до конца main. Три нюанса про recover, о которых легко забыть:
recoverвозвращает не-nilтолько при вызове прямо из отложенной функции во время разворачивания паники. Вызванный где угодно ещё — вернётnilи ничего не сделает.recoverдействует только в своей горутине. Паника в запущеннойgo func()не долетит доdefer/recoverвызывающей горутины: если у самой горутины нет собственногоrecover, непойманная паника роняет весь процесс — сколько быdeferни стояло снаружи. Это классический источник инцидентов: воркер-пул или обработчик, где забылиrecoverв теле каждой горутины, обрушивает сервис целиком из-за одной задачи. Практика — на входе в каждую долгоживущую горутину ставить свойdeferсrecover, логировать панику и не давать одной задаче убить процесс.recoverловитpanic, но неfatal errorрантайма. Одновременная запись в map из нескольких горутин — этоfatal error: concurrent map writes, и она обрушивает процесс мимо всякогоdefer/recover. Почему так и как этого избегать — в сиблинге «Срезы и мапы: внутреннее устройство». Штатное завершение работы под сигналом — тоже не проrecover, а про graceful shutdown.
Демо и версии
- Код: живой стенд
digital-cookbook/go-fundamentals/errors/. Пакетerrorscookbook(errors.go) — sentinel, обёртывание%w, типизированная*ValidationErrorиerrors.Join; тесты (errors_test.go) доказывают, чтоerrors.Is/errors.Asобходят всю обёрнутую цепочку, аJoinнаходит любую из объединённых ошибок. Два запускаемых демо вcmd/:typed-nil(ловушка nil-интерфейса) иpanic-recover(паника →error). Код в тексте — выжимки из стенда. - Версии:
errors.New, sentinel иpanic/recover— с самого Go 1. Обёртывание%wи функцииerrors.Is/errors.As/errors.Unwrapпоявились в Go 1.13.errors.Joinи несколько%wв одномfmt.Errorf— в Go 1.20. Ловушка типизированного nil сверена на go1.26.3; собирайте на текущем стабильном релизе. - Смежные статьи: интерфейсы в Go (почему nil-интерфейс не nil), срезы и мапы (
fatal errorрантайма мимо recover), обработка ошибок в разных языках (Go на фоне исключений иResult), graceful shutdown.
Документация и первоисточники
- Working with Errors in Go 1.13 — первоисточник по обёртыванию: глагол
%w,errors.Is,errors.As,errors.Unwrapи мотивация «ошибки — значения». - Effective Go — Errors и раздел про
panic/recover— идиомы обработки ошибок и границы применимости паники. - Go 1.20 Release Notes —
errors.Joinи поддержка нескольких%wвfmt.Errorf. - Proposal: Error Inspection (errors.Is/As/Unwrap) — дизайн-обоснование механизма разбора цепочки ошибок.
- Package errors — актуальная документация стандартного пакета.
Комментарии